Page MenuHomePhorge

No OneTemporary

Size
241 KB
Referenced Files
None
Subscribers
None
diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php
index 58a59ce6..e46b8f88 100644
--- a/src/app/Http/Controllers/API/V4/PaymentsController.php
+++ b/src/app/Http/Controllers/API/V4/PaymentsController.php
@@ -1,328 +1,475 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\Providers\PaymentProvider;
use App\Wallet;
+use App\Payment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class PaymentsController extends Controller
{
/**
* 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.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateCreate(Request $request)
{
$current_user = Auth::guard()->user();
// TODO: Wallet selection
$wallet = $current_user->wallets()->first();
// Input validation
if ($errors = self::mandateValidate($request, $wallet)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$wallet->setSettings([
'mandate_amount' => $request->amount,
'mandate_balance' => $request->balance,
]);
$mandate = [
'currency' => 'CHF',
'description' => \config('app.name') . ' Auto-Payment Setup',
+ 'methodId' => $request->methodId
];
// Normally the auto-payment setup operation is 0, if the balance is below the threshold
// we'll top-up the wallet with the configured auto-payment amount
if ($wallet->balance < intval($request->balance * 100)) {
$mandate['amount'] = intval($request->amount * 100);
}
$provider = PaymentProvider::factory($wallet);
$result = $provider->createMandate($wallet, $mandate);
$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);
$provider->deleteMandate($wallet);
$wallet->setSetting('mandate_disabled', null);
return response()->json([
'status' => 'success',
'message' => \trans('app.mandate-delete-success'),
]);
}
/**
* Update a new auto-payment mandate.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateUpdate(Request $request)
{
$current_user = Auth::guard()->user();
// TODO: Wallet selection
$wallet = $current_user->wallets()->first();
// Input validation
if ($errors = self::mandateValidate($request, $wallet)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$wallet->setSettings([
'mandate_amount' => $request->amount,
'mandate_balance' => $request->balance,
// Re-enable the mandate to give it a chance to charge again
// after it has been disabled (e.g. because the mandate amount was too small)
'mandate_disabled' => null,
]);
// Trigger auto-payment if the balance is below the threshold
if ($wallet->balance < intval($request->balance * 100)) {
\App\Jobs\WalletCharge::dispatch($wallet);
}
$result = self::walletMandate($wallet);
$result['status'] = 'success';
$result['message'] = \trans('app.mandate-update-success');
return response()->json($result);
}
/**
* Validate an auto-payment mandate request.
*
* @param \Illuminate\Http\Request $request The API request.
* @param \App\Wallet $wallet The wallet
*
* @return array|null List of errors on error or Null on success
*/
protected static function mandateValidate(Request $request, Wallet $wallet)
{
$rules = [
'amount' => 'required|numeric',
'balance' => 'required|numeric|min:0',
];
// Check required fields
$v = Validator::make($request->all(), $rules);
// TODO: allow comma as a decimal point?
if ($v->fails()) {
return $v->errors()->toArray();
}
$amount = (int) ($request->amount * 100);
// Validate the minimum value
// It has to be at least minimum payment amount and must cover current debt
if (
$wallet->balance < 0
&& $wallet->balance * -1 > PaymentProvider::MIN_AMOUNT
&& $wallet->balance + $amount < 0
) {
return ['amount' => \trans('validation.minamountdebt')];
}
if ($amount < PaymentProvider::MIN_AMOUNT) {
$min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF';
return ['amount' => \trans('validation.minamount', ['amount' => $min])];
}
return null;
}
/**
* Create a new payment.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$current_user = Auth::guard()->user();
// TODO: Wallet selection
$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);
}
$amount = (int) ($request->amount * 100);
// Validate the minimum value
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);
}
$request = [
'type' => PaymentProvider::TYPE_ONEOFF,
- 'currency' => 'CHF',
+ 'currency' => $request->currency,
'amount' => $amount,
+ 'methodId' => $request->methodId,
'description' => \config('app.name') . ' Payment',
];
$provider = PaymentProvider::factory($wallet);
$result = $provider->payment($wallet, $request);
$result['status'] = 'success';
return response()->json($result);
}
+ /**
+ * Delete a pending payment.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ // TODO currently unused
+ // public function cancel(Request $request)
+ // {
+ // $current_user = Auth::guard()->user();
+
+ // // TODO: Wallet selection
+ // $wallet = $current_user->wallets()->first();
+
+ // $paymentId = $request->payment;
+
+ // $user_owns_payment = Payment::where('id', $paymentId)
+ // ->where('wallet_id', $wallet->id)
+ // ->exists();
+
+ // if (!$user_owns_payment) {
+ // return $this->errorResponse(404);
+ // }
+
+ // $provider = PaymentProvider::factory($wallet);
+ // if ($provider->cancel($wallet, $paymentId)) {
+ // $result = ['status' => 'success'];
+ // return response()->json($result);
+ // }
+
+ // return $this->errorResponse(404);
+ // }
+
/**
* Update payment status (and balance).
*
* @param string $provider Provider name
*
* @return \Illuminate\Http\Response The response
*/
public function webhook($provider)
{
$code = 200;
if ($provider = PaymentProvider::factory($provider)) {
$code = $provider->webhook();
}
return response($code < 400 ? 'Success' : 'Server error', $code);
}
/**
* Top up a wallet with a "recurring" payment.
*
* @param \App\Wallet $wallet The wallet to charge
*
* @return bool True if the payment has been initialized
*/
public static function topUpWallet(Wallet $wallet): bool
{
if ((bool) $wallet->getSetting('mandate_disabled')) {
return false;
}
$min_balance = (int) (floatval($wallet->getSetting('mandate_balance')) * 100);
$amount = (int) (floatval($wallet->getSetting('mandate_amount')) * 100);
// The wallet balance is greater than the auto-payment threshold
if ($wallet->balance >= $min_balance) {
// Do nothing
return false;
}
$provider = PaymentProvider::factory($wallet);
$mandate = (array) $provider->getMandate($wallet);
if (empty($mandate['isValid'])) {
return false;
}
// The defined top-up amount is not enough
// Disable auto-payment and notify the user
if ($wallet->balance + $amount < 0) {
// Disable (not remove) the mandate
$wallet->setSetting('mandate_disabled', 1);
\App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet);
return false;
}
$request = [
'type' => PaymentProvider::TYPE_RECURRING,
'currency' => 'CHF',
'amount' => $amount,
+ 'methodId' => PaymentProvider::METHOD_CREDITCARD,
'description' => \config('app.name') . ' Recurring Payment',
];
$result = $provider->payment($wallet, $request);
return !empty($result);
}
/**
* Returns auto-payment mandate info for the specified wallet
*
* @param \App\Wallet $wallet A wallet object
*
* @return array A mandate metadata
*/
public static function walletMandate(Wallet $wallet): array
{
$provider = PaymentProvider::factory($wallet);
// Get the Mandate info
$mandate = (array) $provider->getMandate($wallet);
$mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100);
$mandate['balance'] = 0;
$mandate['isDisabled'] = !empty($mandate['id']) && $wallet->getSetting('mandate_disabled');
foreach (['amount', 'balance'] as $key) {
if (($value = $wallet->getSetting("mandate_{$key}")) !== null) {
$mandate[$key] = $value;
}
}
return $mandate;
}
+
+
+ /**
+ * List supported payment methods.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public static function paymentMethods(Request $request)
+ {
+ $user = Auth::guard()->user();
+
+ // TODO: Wallet selection
+ $wallet = $user->wallets()->first();
+
+ $methods = PaymentProvider::paymentMethods($wallet, $request->type);
+
+ \Log::debug("Provider methods" . var_export(json_encode($methods), true));
+
+ return response()->json($methods);
+ }
+
+ /**
+ * Check for pending payments.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public static function hasPayments(Request $request)
+ {
+ $user = Auth::guard()->user();
+
+ // TODO: Wallet selection
+ $wallet = $user->wallets()->first();
+
+ $exists = Payment::where('wallet_id', $wallet->id)
+ ->where('type', PaymentProvider::TYPE_ONEOFF)
+ ->whereIn('status', [
+ PaymentProvider::STATUS_OPEN,
+ PaymentProvider::STATUS_PENDING,
+ PaymentProvider::STATUS_AUTHORIZED])
+ ->exists();
+
+ return response()->json([
+ 'status' => 'success',
+ 'hasPending' => $exists
+ ]);
+ }
+
+ /**
+ * List pending payments.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public static function payments(Request $request)
+ {
+ $user = Auth::guard()->user();
+
+ // TODO: Wallet selection
+ $wallet = $user->wallets()->first();
+
+ $pageSize = 10;
+ $page = intval(request()->input('page')) ?: 1;
+ $hasMore = false;
+ $result = Payment::where('wallet_id', $wallet->id)
+ ->where('type', PaymentProvider::TYPE_ONEOFF)
+ ->whereIn('status', [
+ PaymentProvider::STATUS_OPEN,
+ PaymentProvider::STATUS_PENDING,
+ PaymentProvider::STATUS_AUTHORIZED])
+ ->orderBy('created_at', 'desc')
+ ->limit($pageSize + 1)
+ ->offset($pageSize * ($page - 1))
+ ->get();
+
+ if (count($result) > $pageSize) {
+ $result->pop();
+ $hasMore = true;
+ }
+
+ $result = $result->map(function ($item) {
+ $provider = PaymentProvider::factory($item->provider);
+ $payment = $provider->getPayment($item->id);
+ $entry = [
+ 'id' => $item->id,
+ 'createdAt' => $item->created_at->format('Y-m-d H:i'),
+ 'type' => $item->type,
+ 'description' => $item->description,
+ 'amount' => $item->amount,
+ 'status' => $item->status,
+ 'isCancelable' => $payment['isCancelable'],
+ 'checkoutUrl' => $payment['checkoutUrl']
+ ];
+
+ return $entry;
+ });
+
+ return response()->json([
+ 'status' => 'success',
+ 'list' => $result,
+ 'count' => count($result),
+ 'hasMore' => $hasMore,
+ 'page' => $page,
+ ]);
+ }
}
diff --git a/src/app/Payment.php b/src/app/Payment.php
index f501decc..ba088b68 100644
--- a/src/app/Payment.php
+++ b/src/app/Payment.php
@@ -1,48 +1,61 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
/**
* A payment operation on a wallet.
*
- * @property int $amount Amount of money in cents
+ * @property int $amount Amount of money in cents of CHF
* @property string $description Payment description
* @property string $id Mollie's Payment ID
* @property \App\Wallet $wallet The wallet
* @property string $wallet_id The ID of the wallet
+ * @property string $currency Currency of this payment
+ * @property int $currency_amount Amount of money in cents of $currency
*/
class Payment extends Model
{
public $incrementing = false;
protected $keyType = 'string';
protected $casts = [
'amount' => 'integer'
];
protected $fillable = [
'id',
'wallet_id',
'amount',
'description',
'provider',
'status',
'type',
+ 'currency',
+ 'currency_amount',
];
+
+ /**
+ * Ensure the currency is appropriately cased.
+ */
+ public function setCurrencyAttribute($currency)
+ {
+ $this->attributes['currency'] = strtoupper($currency);
+ }
+
/**
* The wallet to which this payment belongs.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function wallet()
{
return $this->belongsTo(
'\App\Wallet',
'wallet_id', /* local */
'id' /* remote */
);
}
}
diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php
index 450af342..bdbf867a 100644
--- a/src/app/Providers/Payment/Mollie.php
+++ b/src/app/Providers/Payment/Mollie.php
@@ -1,513 +1,622 @@
<?php
namespace App\Providers\Payment;
use App\Payment;
use App\Utils;
use App\Wallet;
use Illuminate\Support\Facades\DB;
use Mollie\Api\Exceptions\ApiException;
use Mollie\Api\Types;
class Mollie extends \App\Providers\PaymentProvider
{
/**
* Get a link to the customer in the provider's control panel
*
* @param \App\Wallet $wallet The wallet
*
* @return string|null The string representing <a> tag
*/
public function customerLink(Wallet $wallet): ?string
{
$customer_id = self::mollieCustomerId($wallet, false);
if (!$customer_id) {
return null;
}
return sprintf(
'<a href="https://www.mollie.com/dashboard/customers/%s" target="_blank">%s</a>',
$customer_id,
$customer_id
);
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents (optional)
* - currency: The operation currency
* - description: Operation desc.
+ * - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
public function createMandate(Wallet $wallet, array $payment): ?array
{
// Register the user in Mollie, if not yet done
$customer_id = self::mollieCustomerId($wallet, true);
if (!isset($payment['amount'])) {
$payment['amount'] = 0;
}
+ $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
+ $payment['currency_amount'] = $amount;
+
$request = [
'amount' => [
'currency' => $payment['currency'],
- 'value' => sprintf('%.2f', $payment['amount'] / 100),
+ 'value' => sprintf('%.2f', $amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => 'first',
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'redirectUrl' => Utils::serviceUrl('/wallet'),
'locale' => 'en_US',
- // 'method' => 'creditcard',
+ 'method' => $payment['methodId']
];
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
if ($response->mandateId) {
$wallet->setSetting('mollie_mandate_id', $response->mandateId);
}
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
$payment['type'] = self::TYPE_MANDATE;
$this->storePayment($payment, $wallet->id);
return [
'id' => $response->id,
'redirectUrl' => $response->getCheckoutUrl(),
];
}
/**
* Revoke the auto-payment mandate for the wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
public function deleteMandate(Wallet $wallet): bool
{
// Get the Mandate info
$mandate = self::mollieMandate($wallet);
// Revoke the mandate on Mollie
if ($mandate) {
$mandate->revoke();
$wallet->setSetting('mollie_mandate_id', null);
}
return true;
}
/**
* Get a auto-payment mandate for the wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
+ * - methodId: Payment method
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
public function getMandate(Wallet $wallet): ?array
{
// Get the Mandate info
$mandate = self::mollieMandate($wallet);
if (empty($mandate)) {
return null;
}
$result = [
'id' => $mandate->id,
'isPending' => $mandate->isPending(),
'isValid' => $mandate->isValid(),
- 'method' => self::paymentMethod($mandate, 'Unknown method')
+ 'method' => self::paymentMethod($mandate, 'Unknown method'),
+ 'methodId' => $mandate->method
];
return $result;
}
/**
* Get a provider name
*
* @return string Provider name
*/
public function name(): string
{
return 'mollie';
}
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - type: oneoff/recurring
* - description: Operation desc.
+ * - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
public function payment(Wallet $wallet, array $payment): ?array
{
if ($payment['type'] == self::TYPE_RECURRING) {
return $this->paymentRecurring($wallet, $payment);
}
// Register the user in Mollie, if not yet done
$customer_id = self::mollieCustomerId($wallet, true);
- // Note: Required fields: description, amount/currency, amount/value
+ $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
+ $payment['currency_amount'] = $amount;
+ // Note: Required fields: description, amount/currency, amount/value
$request = [
'amount' => [
'currency' => $payment['currency'],
- // a number with two decimals is required
- 'value' => sprintf('%.2f', $payment['amount'] / 100),
+ // a number with two decimals is required (note that JPK and ISK don't require decimals,
+ // but we're not using them currently)
+ 'value' => sprintf('%.2f', $amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => $payment['type'],
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
- // 'method' => 'creditcard',
+ 'method' => $payment['methodId'],
'redirectUrl' => Utils::serviceUrl('/wallet') // required for non-recurring payments
];
// TODO: Additional payment parameters for better fraud protection:
// billingEmail - for bank transfers, Przelewy24, but not creditcard
// billingAddress (it is a structured field not just text)
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
$this->storePayment($payment, $wallet->id);
return [
'id' => $payment['id'],
'redirectUrl' => $response->getCheckoutUrl(),
];
}
+
+ /**
+ * Cancel a pending payment.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param string $paymentId Payment Id
+ *
+ * @return bool True on success, False on failure
+ */
+ public function cancel(Wallet $wallet, $paymentId): bool
+ {
+ $response = mollie()->payments()->delete($paymentId);
+
+ $db_payment = Payment::find($paymentId);
+ $db_payment->status = $response->status;
+ $db_payment->save();
+
+ return true;
+ }
+
+
/**
* Create a new automatic payment operation.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data (see self::payment())
*
* @return array Provider payment/session data:
* - id: Operation identifier
*/
protected function paymentRecurring(Wallet $wallet, array $payment): ?array
{
// Check if there's a valid mandate
$mandate = self::mollieMandate($wallet);
if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) {
return null;
}
$customer_id = self::mollieCustomerId($wallet, true);
// Note: Required fields: description, amount/currency, amount/value
+ $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
+ $payment['currency_amount'] = $amount;
$request = [
'amount' => [
'currency' => $payment['currency'],
// a number with two decimals is required
- 'value' => sprintf('%.2f', $payment['amount'] / 100),
+ 'value' => sprintf('%.2f', $amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => $payment['type'],
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
- // 'method' => 'creditcard',
+ 'method' => $payment['methodId'],
'mandateId' => $mandate->id
];
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
DB::beginTransaction();
$payment = $this->storePayment($payment, $wallet->id);
// Mollie can return 'paid' status immediately, so we don't
// have to wait for the webhook. What's more, the webhook would ignore
// the payment because it will be marked as paid before the webhook.
// Let's handle paid status here too.
if ($response->isPaid()) {
self::creditPayment($payment, $response);
$notify = true;
} elseif ($response->isFailed()) {
// Note: I didn't find a way to get any description of the problem with a payment
\Log::info(sprintf('Mollie payment failed (%s)', $response->id));
// Disable the mandate
$wallet->setSetting('mandate_disabled', 1);
$notify = true;
}
DB::commit();
if (!empty($notify)) {
\App\Jobs\PaymentEmail::dispatch($payment);
}
return [
'id' => $payment['id'],
];
}
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
public function webhook(): int
{
$payment_id = \request()->input('id');
if (empty($payment_id)) {
return 200;
}
$payment = Payment::find($payment_id);
if (empty($payment)) {
// Mollie recommends to return "200 OK" even if the payment does not exist
return 200;
}
// Get the payment details from Mollie
// TODO: Consider https://github.com/mollie/mollie-api-php/issues/502 when it's fixed
$mollie_payment = mollie()->payments()->get($payment_id);
if (empty($mollie_payment)) {
// Mollie recommends to return "200 OK" even if the payment does not exist
return 200;
}
$refunds = [];
if ($mollie_payment->isPaid()) {
// The payment is paid. Update the balance, and notify the user
if ($payment->status != self::STATUS_PAID && $payment->amount > 0) {
$credit = true;
$notify = $payment->type == self::TYPE_RECURRING;
}
// The payment has been (partially) refunded.
// Let's process refunds with status "refunded".
if ($mollie_payment->hasRefunds()) {
foreach ($mollie_payment->refunds() as $refund) {
if ($refund->isTransferred() && $refund->amount->value) {
$refunds[] = [
'id' => $refund->id,
'description' => $refund->description,
'amount' => round(floatval($refund->amount->value) * 100),
'type' => self::TYPE_REFUND,
- // Note: we assume this is the original payment/wallet currency
+ 'currency' => $refund->amount->currency
];
}
}
}
// The payment has been (partially) charged back.
// Let's process chargebacks (they have no states as refunds)
if ($mollie_payment->hasChargebacks()) {
foreach ($mollie_payment->chargebacks() as $chargeback) {
if ($chargeback->amount->value) {
$refunds[] = [
'id' => $chargeback->id,
'amount' => round(floatval($chargeback->amount->value) * 100),
'type' => self::TYPE_CHARGEBACK,
- // Note: we assume this is the original payment/wallet currency
+ 'currency' => $chargeback->amount->currency
];
}
}
}
// In case there were multiple auto-payment setup requests (e.g. caused by a double
// form submission) we end up with multiple payment records and mollie_mandate_id
// pointing to the one from the last payment not the successful one.
// We make sure to use mandate id from the successful "first" payment.
if (
$payment->type == self::TYPE_MANDATE
&& $mollie_payment->mandateId
&& $mollie_payment->sequenceType == Types\SequenceType::SEQUENCETYPE_FIRST
) {
$payment->wallet->setSetting('mollie_mandate_id', $mollie_payment->mandateId);
}
} elseif ($mollie_payment->isFailed()) {
// Note: I didn't find a way to get any description of the problem with a payment
\Log::info(sprintf('Mollie payment failed (%s)', $payment->id));
// Disable the mandate
if ($payment->type == self::TYPE_RECURRING) {
$notify = true;
$payment->wallet->setSetting('mandate_disabled', 1);
}
}
DB::beginTransaction();
// This is a sanity check, just in case the payment provider api
// sent us open -> paid -> open -> paid. So, we lock the payment after
// recivied a "final" state.
$pending_states = [self::STATUS_OPEN, self::STATUS_PENDING, self::STATUS_AUTHORIZED];
if (in_array($payment->status, $pending_states)) {
$payment->status = $mollie_payment->status;
$payment->save();
}
if (!empty($credit)) {
self::creditPayment($payment, $mollie_payment);
}
foreach ($refunds as $refund) {
$this->storeRefund($payment->wallet, $refund);
}
DB::commit();
if (!empty($notify)) {
\App\Jobs\PaymentEmail::dispatch($payment);
}
return 200;
}
/**
* Get Mollie customer identifier for specified wallet.
* Create one if does not exist yet.
*
* @param \App\Wallet $wallet The wallet
* @param bool $create Create the customer if does not exist yet
*
* @return ?string Mollie customer identifier
*/
protected static function mollieCustomerId(Wallet $wallet, bool $create = false): ?string
{
$customer_id = $wallet->getSetting('mollie_id');
// Register the user in Mollie
if (empty($customer_id) && $create) {
$customer = mollie()->customers()->create([
'name' => $wallet->owner->name(),
'email' => $wallet->id . '@private.' . \config('app.domain'),
]);
$customer_id = $customer->id;
$wallet->setSetting('mollie_id', $customer->id);
}
return $customer_id;
}
/**
* Get the active Mollie auto-payment mandate
*/
protected static function mollieMandate(Wallet $wallet)
{
$customer_id = $wallet->getSetting('mollie_id');
$mandate_id = $wallet->getSetting('mollie_mandate_id');
// Get the manadate reference we already have
if ($customer_id && $mandate_id) {
try {
return mollie()->mandates()->getForId($customer_id, $mandate_id);
} catch (ApiException $e) {
// FIXME: What about 404?
if ($e->getCode() == 410) {
// The mandate is gone, remove the reference
$wallet->setSetting('mollie_mandate_id', null);
return null;
}
// TODO: Maybe we shouldn't always throw? It make sense in the job
// but for example when we're just fetching wallet info...
throw $e;
}
}
}
/**
* Apply the successful payment's pecunia to the wallet
*/
protected static function creditPayment($payment, $mollie_payment)
{
// Extract the payment method for transaction description
$method = self::paymentMethod($mollie_payment, 'Mollie');
// TODO: Localization?
$description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment';
$description .= " transaction {$payment->id} using {$method}";
$payment->wallet->credit($payment->amount, $description);
// Unlock the disabled auto-payment mandate
if ($payment->wallet->balance >= 0) {
$payment->wallet->setSetting('mandate_disabled', null);
}
}
/**
* Extract payment method description from Mollie payment/mandate details
*/
protected static function paymentMethod($object, $default = ''): string
{
$details = $object->details;
// Mollie supports 3 methods here
switch ($object->method) {
- case 'creditcard':
+ case self::METHOD_CREDITCARD:
// If the customer started, but never finished the 'first' payment
// card details will be empty, and mandate will be 'pending'.
if (empty($details->cardNumber)) {
return 'Credit Card';
}
return sprintf(
'%s (**** **** **** %s)',
$details->cardLabel ?: 'Card', // @phpstan-ignore-line
$details->cardNumber
);
- case 'directdebit':
+ case self::METHOD_DIRECTDEBIT:
return sprintf('Direct Debit (%s)', $details->customerAccount);
- case 'paypal':
+ case self::METHOD_PAYPAL:
return sprintf('PayPal (%s)', $details->consumerAccount);
}
return $default;
}
+
+ /**
+ * List supported payment methods.
+ *
+ * @param string $type The payment type for which we require a method (oneoff/recurring).
+ *
+ * @return array Array of array with available payment methods:
+ * - id: id of the method
+ * - name: User readable name of the payment method
+ * - minimumAmount: Minimum amount to be charged in cents
+ * - currency: Currency used for the method
+ * - exchangeRate: The projected exchange rate (actual rate is determined during payment)
+ * - icon: An icon (icon name) representing the method
+ */
+ public function providerPaymentMethods($type): array
+ {
+
+ $providerMethods = array_merge(
+ // Fallback to EUR methods (later provider methods will override earlier ones)
+ //mollie()->methods()->allActive(
+ // [
+ // 'sequenceType' => $type,
+ // 'amount' => [
+ // 'value' => '1.00',
+ // 'currency' => 'EUR'
+ // ]
+ // ]
+ //),
+ // Prefer CHF methods
+ (array)mollie()->methods()->allActive(
+ [
+ 'sequenceType' => $type,
+ 'amount' => [
+ 'value' => '1.00',
+ 'currency' => 'CHF'
+ ]
+ ]
+ )
+ );
+
+ $availableMethods = [];
+ foreach ($providerMethods as $method) {
+ $availableMethods[$method->id] = [
+ 'id' => $method->id,
+ 'name' => $method->description,
+ 'minimumAmount' => round(floatval($method->minimumAmount->value) * 100), // Converted to cents
+ 'currency' => $method->minimumAmount->currency,
+ 'exchangeRate' => $this->exchangeRate('CHF', $method->minimumAmount->currency)
+ ];
+ }
+
+ return $availableMethods;
+ }
+
+ /**
+ * Get a payment.
+ *
+ * @param string $paymentId Payment identifier
+ *
+ * @return array Payment information:
+ * - id: Payment identifier
+ * - status: Payment status
+ * - isCancelable: The payment can be canceled
+ * - checkoutUrl: The checkout url to complete the payment or null if none
+ */
+ public function getPayment($paymentId): array
+ {
+ $payment = mollie()->payments()->get($paymentId);
+
+ return [
+ 'id' => $payment->id,
+ 'status' => $payment->status,
+ 'isCancelable' => $payment->isCancelable,
+ 'checkoutUrl' => $payment->getCheckoutUrl()
+ ];
+ }
}
diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php
index 58fd8ba6..6c20642d 100644
--- a/src/app/Providers/Payment/Stripe.php
+++ b/src/app/Providers/Payment/Stripe.php
@@ -1,473 +1,558 @@
<?php
namespace App\Providers\Payment;
use App\Payment;
use App\Utils;
use App\Wallet;
use App\WalletSetting;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Request;
use Stripe as StripeAPI;
class Stripe extends \App\Providers\PaymentProvider
{
/**
* Class constructor.
*/
public function __construct()
{
StripeAPI\Stripe::setApiKey(\config('services.stripe.key'));
}
/**
* Get a link to the customer in the provider's control panel
*
* @param \App\Wallet $wallet The wallet
*
* @return string|null The string representing <a> tag
*/
public function customerLink(Wallet $wallet): ?string
{
$customer_id = self::stripeCustomerId($wallet, false);
if (!$customer_id) {
return null;
}
$location = 'https://dashboard.stripe.com';
$key = \config('services.stripe.key');
if (strpos($key, 'sk_test_') === 0) {
$location .= '/test';
}
return sprintf(
'<a href="%s/customers/%s" target="_blank">%s</a>',
$location,
$customer_id,
$customer_id
);
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents (not used)
* - currency: The operation currency
* - description: Operation desc.
*
* @return array Provider payment/session data:
* - id: Session identifier
*/
public function createMandate(Wallet $wallet, array $payment): ?array
{
// Register the user in Stripe, if not yet done
$customer_id = self::stripeCustomerId($wallet, true);
$request = [
'customer' => $customer_id,
'cancel_url' => Utils::serviceUrl('/wallet'), // required
'success_url' => Utils::serviceUrl('/wallet'), // required
'payment_method_types' => ['card'], // required
'locale' => 'en',
'mode' => 'setup',
];
// Note: Stripe does not allow to set amount for 'setup' operation
// We'll dispatch WalletCharge job when we receive a webhook request
$session = StripeAPI\Checkout\Session::create($request);
$payment['amount'] = 0;
+ $payment['currency_amount'] = 0;
$payment['id'] = $session->setup_intent;
$payment['type'] = self::TYPE_MANDATE;
$this->storePayment($payment, $wallet->id);
return [
'id' => $session->id,
];
}
/**
* Revoke the auto-payment mandate.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
public function deleteMandate(Wallet $wallet): bool
{
// Get the Mandate info
$mandate = self::stripeMandate($wallet);
if ($mandate) {
// Remove the reference
$wallet->setSetting('stripe_mandate_id', null);
// Detach the payment method on Stripe
$pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method);
$pm->detach();
}
return true;
}
/**
* Get a auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
public function getMandate(Wallet $wallet): ?array
{
// Get the Mandate info
$mandate = self::stripeMandate($wallet);
if (empty($mandate)) {
return null;
}
$pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method);
$result = [
'id' => $mandate->id,
'isPending' => $mandate->status != 'succeeded' && $mandate->status != 'canceled',
'isValid' => $mandate->status == 'succeeded',
'method' => self::paymentMethod($pm, 'Unknown method')
];
return $result;
}
/**
* Get a provider name
*
* @return string Provider name
*/
public function name(): string
{
return 'stripe';
}
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - type: first/oneoff/recurring
* - description: Operation desc.
*
* @return array Provider payment/session data:
* - id: Session identifier
*/
public function payment(Wallet $wallet, array $payment): ?array
{
if ($payment['type'] == self::TYPE_RECURRING) {
return $this->paymentRecurring($wallet, $payment);
}
// Register the user in Stripe, if not yet done
$customer_id = self::stripeCustomerId($wallet, true);
+
+ $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
+ $payment['currency_amount'] = $amount;
+
$request = [
'customer' => $customer_id,
'cancel_url' => Utils::serviceUrl('/wallet'), // required
'success_url' => Utils::serviceUrl('/wallet'), // required
'payment_method_types' => ['card'], // required
'locale' => 'en',
'line_items' => [
[
'name' => $payment['description'],
- 'amount' => $payment['amount'],
+ 'amount' => $amount,
'currency' => \strtolower($payment['currency']),
'quantity' => 1,
]
]
];
$session = StripeAPI\Checkout\Session::create($request);
// Store the payment reference in database
$payment['id'] = $session->payment_intent;
$this->storePayment($payment, $wallet->id);
return [
'id' => $session->id,
];
}
/**
* Create a new automatic payment operation.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data (see self::payment())
*
* @return array Provider payment/session data:
* - id: Session identifier
*/
protected function paymentRecurring(Wallet $wallet, array $payment): ?array
{
// Check if there's a valid mandate
$mandate = self::stripeMandate($wallet);
if (empty($mandate)) {
return null;
}
+ $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
+ $payment['currency_amount'] = $amount;
+
$request = [
- 'amount' => $payment['amount'],
+ 'amount' => $amount,
'currency' => \strtolower($payment['currency']),
'description' => $payment['description'],
'receipt_email' => $wallet->owner->email,
'customer' => $mandate->customer,
'payment_method' => $mandate->payment_method,
'off_session' => true,
'confirm' => true,
];
$intent = StripeAPI\PaymentIntent::create($request);
// Store the payment reference in database
$payment['id'] = $intent->id;
$this->storePayment($payment, $wallet->id);
return [
'id' => $payment['id'],
];
}
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
public function webhook(): int
{
// We cannot just use php://input as it's already "emptied" by the framework
// $payload = file_get_contents('php://input');
$request = Request::instance();
$payload = $request->getContent();
$sig_header = $request->header('Stripe-Signature');
// Parse and validate the input
try {
$event = StripeAPI\Webhook::constructEvent(
$payload,
$sig_header,
\config('services.stripe.webhook_secret')
);
} catch (\Exception $e) {
+ \Log::error("Invalid payload: " . $e->getMessage());
// Invalid payload
return 400;
}
switch ($event->type) {
case StripeAPI\Event::PAYMENT_INTENT_CANCELED:
case StripeAPI\Event::PAYMENT_INTENT_PAYMENT_FAILED:
case StripeAPI\Event::PAYMENT_INTENT_SUCCEEDED:
$intent = $event->data->object; // @phpstan-ignore-line
$payment = Payment::find($intent->id);
if (empty($payment) || $payment->type == self::TYPE_MANDATE) {
return 404;
}
switch ($intent->status) {
case StripeAPI\PaymentIntent::STATUS_CANCELED:
$status = self::STATUS_CANCELED;
break;
case StripeAPI\PaymentIntent::STATUS_SUCCEEDED:
$status = self::STATUS_PAID;
break;
default:
$status = self::STATUS_FAILED;
}
DB::beginTransaction();
if ($status == self::STATUS_PAID) {
// Update the balance, if it wasn't already
if ($payment->status != self::STATUS_PAID) {
$this->creditPayment($payment, $intent);
}
} else {
if (!empty($intent->last_payment_error)) {
// See https://stripe.com/docs/error-codes for more info
\Log::info(sprintf(
'Stripe payment failed (%s): %s',
$payment->id,
json_encode($intent->last_payment_error)
));
}
}
if ($payment->status != self::STATUS_PAID) {
$payment->status = $status;
$payment->save();
if ($status != self::STATUS_CANCELED && $payment->type == self::TYPE_RECURRING) {
// Disable the mandate
if ($status == self::STATUS_FAILED) {
$payment->wallet->setSetting('mandate_disabled', 1);
}
// Notify the user
\App\Jobs\PaymentEmail::dispatch($payment);
}
}
DB::commit();
break;
case StripeAPI\Event::SETUP_INTENT_SUCCEEDED:
case StripeAPI\Event::SETUP_INTENT_SETUP_FAILED:
case StripeAPI\Event::SETUP_INTENT_CANCELED:
$intent = $event->data->object; // @phpstan-ignore-line
$payment = Payment::find($intent->id);
if (empty($payment) || $payment->type != self::TYPE_MANDATE) {
return 404;
}
switch ($intent->status) {
case StripeAPI\SetupIntent::STATUS_CANCELED:
$status = self::STATUS_CANCELED;
break;
case StripeAPI\SetupIntent::STATUS_SUCCEEDED:
$status = self::STATUS_PAID;
break;
default:
$status = self::STATUS_FAILED;
}
if ($status == self::STATUS_PAID) {
$payment->wallet->setSetting('stripe_mandate_id', $intent->id);
$threshold = intval((float) $payment->wallet->getSetting('mandate_balance') * 100);
// Top-up the wallet if balance is below the threshold
if ($payment->wallet->balance < $threshold && $payment->status != self::STATUS_PAID) {
\App\Jobs\WalletCharge::dispatch($payment->wallet);
}
}
$payment->status = $status;
$payment->save();
break;
default:
\Log::debug("Unhandled Stripe event: " . var_export($payload, true));
break;
}
return 200;
}
/**
* Get Stripe customer identifier for specified wallet.
* Create one if does not exist yet.
*
* @param \App\Wallet $wallet The wallet
* @param bool $create Create the customer if does not exist yet
*
* @return string|null Stripe customer identifier
*/
protected static function stripeCustomerId(Wallet $wallet, bool $create = false): ?string
{
$customer_id = $wallet->getSetting('stripe_id');
// Register the user in Stripe
if (empty($customer_id) && $create) {
$customer = StripeAPI\Customer::create([
'name' => $wallet->owner->name(),
// Stripe will display the email on Checkout page, editable,
// and use it to send the receipt (?), use the user email here
// 'email' => $wallet->id . '@private.' . \config('app.domain'),
'email' => $wallet->owner->email,
]);
$customer_id = $customer->id;
$wallet->setSetting('stripe_id', $customer->id);
}
return $customer_id;
}
/**
* Get the active Stripe auto-payment mandate (Setup Intent)
*/
protected static function stripeMandate(Wallet $wallet)
{
// Note: Stripe also has 'Mandate' objects, but we do not use these
if ($mandate_id = $wallet->getSetting('stripe_mandate_id')) {
$mandate = StripeAPI\SetupIntent::retrieve($mandate_id);
// @phpstan-ignore-next-line
if ($mandate && $mandate->status != 'canceled') {
return $mandate;
}
}
}
/**
* Apply the successful payment's pecunia to the wallet
*/
protected static function creditPayment(Payment $payment, $intent)
{
$method = 'Stripe';
// Extract the payment method for transaction description
if (
!empty($intent->charges)
&& ($charge = $intent->charges->data[0])
&& ($pm = $charge->payment_method_details)
) {
$method = self::paymentMethod($pm);
}
// TODO: Localization?
$description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment';
$description .= " transaction {$payment->id} using {$method}";
$payment->wallet->credit($payment->amount, $description);
// Unlock the disabled auto-payment mandate
if ($payment->wallet->balance >= 0) {
$payment->wallet->setSetting('mandate_disabled', null);
}
}
/**
* Extract payment method description from Stripe payment details
*/
protected static function paymentMethod($details, $default = ''): string
{
switch ($details->type) {
case 'card':
// TODO: card number
return \sprintf(
'%s (**** **** **** %s)',
\ucfirst($details->card->brand) ?: 'Card',
$details->card->last4
);
}
return $default;
}
+
+ /**
+ * List supported payment methods.
+ *
+ * @param string $type The payment type for which we require a method (oneoff/recurring).
+ *
+ * @return array Array of array with available payment methods:
+ * - id: id of the method
+ * - name: User readable name of the payment method
+ * - minimumAmount: Minimum amount to be charged in cents
+ * - currency: Currency used for the method
+ * - exchangeRate: The projected exchange rate (actual rate is determined during payment)
+ * - icon: An icon (icon name) representing the method
+ */
+ public function providerPaymentMethods($type): array
+ {
+ //TODO get this from the stripe API?
+ $availableMethods = [];
+ switch ($type) {
+ case self::TYPE_ONEOFF:
+ $availableMethods = [
+ self::METHOD_CREDITCARD => [
+ 'id' => self::METHOD_CREDITCARD,
+ 'name' => "Credit Card",
+ 'minimumAmount' => self::MIN_AMOUNT,
+ 'currency' => 'CHF',
+ 'exchangeRate' => 1.0
+ ],
+ self::METHOD_PAYPAL => [
+ 'id' => self::METHOD_PAYPAL,
+ 'name' => "PayPal",
+ 'minimumAmount' => self::MIN_AMOUNT,
+ 'currency' => 'CHF',
+ 'exchangeRate' => 1.0
+ ]
+ ];
+ break;
+ case self::TYPE_RECURRING:
+ $availableMethods = [
+ self::METHOD_CREDITCARD => [
+ 'id' => self::METHOD_CREDITCARD,
+ 'name' => "Credit Card",
+ 'minimumAmount' => self::MIN_AMOUNT, // Converted to cents,
+ 'currency' => 'CHF',
+ 'exchangeRate' => 1.0
+ ]
+ ];
+ break;
+ }
+
+ return $availableMethods;
+ }
+
+ /**
+ * Get a payment.
+ *
+ * @param string $paymentId Payment identifier
+ *
+ * @return array Payment information:
+ * - id: Payment identifier
+ * - status: Payment status
+ * - isCancelable: The payment can be canceled
+ * - checkoutUrl: The checkout url to complete the payment or null if none
+ */
+ public function getPayment($paymentId): array
+ {
+ \Log::info("Stripe::getPayment does not yet retrieve a checkoutUrl.");
+
+ $payment = StripeAPI\PaymentIntent::retrieve($paymentId);
+ return [
+ 'id' => $payment->id,
+ 'status' => $payment->status,
+ 'isCancelable' => false,
+ 'checkoutUrl' => null
+ ];
+ }
}
diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php
index aa27ceca..30d41a8e 100644
--- a/src/app/Providers/PaymentProvider.php
+++ b/src/app/Providers/PaymentProvider.php
@@ -1,197 +1,388 @@
<?php
namespace App\Providers;
use App\Transaction;
use App\Payment;
use App\Wallet;
+use Illuminate\Support\Facades\Cache;
abstract class PaymentProvider
{
public const STATUS_OPEN = 'open';
public const STATUS_CANCELED = 'canceled';
public const STATUS_PENDING = 'pending';
public const STATUS_AUTHORIZED = 'authorized';
public const STATUS_EXPIRED = 'expired';
public const STATUS_FAILED = 'failed';
public const STATUS_PAID = 'paid';
public const TYPE_ONEOFF = 'oneoff';
public const TYPE_RECURRING = 'recurring';
public const TYPE_MANDATE = 'mandate';
public const TYPE_REFUND = 'refund';
public const TYPE_CHARGEBACK = 'chargeback';
+ public const METHOD_CREDITCARD = 'creditcard';
+ public const METHOD_PAYPAL = 'paypal';
+ public const METHOD_BANKTRANSFER = 'banktransfer';
+ public const METHOD_DIRECTDEBIT = 'directdebit';
+
+ public const PROVIDER_MOLLIE = 'mollie';
+ public const PROVIDER_STRIPE = 'stripe';
+
/** const int Minimum amount of money in a single payment (in cents) */
public const MIN_AMOUNT = 1000;
+ private static $paymentMethodIcons = [
+ self::METHOD_CREDITCARD => ['prefix' => 'far', 'name' => 'credit-card'],
+ self::METHOD_PAYPAL => ['prefix' => 'fab', 'name' => 'paypal'],
+ self::METHOD_BANKTRANSFER => ['prefix' => 'fas', 'name' => 'university']
+ ];
+
/**
- * Factory method
+ * Detect the name of the provider
*
* @param \App\Wallet|string|null $provider_or_wallet
+ * @return string The name of the provider
*/
- public static function factory($provider_or_wallet = null)
+ private static function providerName($provider_or_wallet = null): string
{
if ($provider_or_wallet instanceof Wallet) {
if ($provider_or_wallet->getSetting('stripe_id')) {
- $provider = 'stripe';
+ $provider = self::PROVIDER_STRIPE;
} elseif ($provider_or_wallet->getSetting('mollie_id')) {
- $provider = 'mollie';
+ $provider = self::PROVIDER_MOLLIE;
}
} else {
$provider = $provider_or_wallet;
}
if (empty($provider)) {
- $provider = \config('services.payment_provider') ?: 'mollie';
+ $provider = \config('services.payment_provider') ?: self::PROVIDER_MOLLIE;
}
- switch (\strtolower($provider)) {
- case 'stripe':
+ return \strtolower($provider);
+ }
+
+ /**
+ * Factory method
+ *
+ * @param \App\Wallet|string|null $provider_or_wallet
+ */
+ public static function factory($provider_or_wallet = null)
+ {
+ switch (self::providerName($provider_or_wallet)) {
+ case self::PROVIDER_STRIPE:
return new \App\Providers\Payment\Stripe();
- case 'mollie':
+ case self::PROVIDER_MOLLIE:
return new \App\Providers\Payment\Mollie();
default:
- throw new \Exception("Invalid payment provider: {$provider}");
+ throw new \Exception("Invalid payment provider: {$provider_or_wallet}");
}
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - description: Operation desc.
+ * - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
abstract public function createMandate(Wallet $wallet, array $payment): ?array;
/**
* Revoke the auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
abstract public function deleteMandate(Wallet $wallet): bool;
/**
* Get a auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
+ * - methodId: Payment method
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
abstract public function getMandate(Wallet $wallet): ?array;
/**
* Get a link to the customer in the provider's control panel
*
* @param \App\Wallet $wallet The wallet
*
* @return string|null The string representing <a> tag
*/
abstract public function customerLink(Wallet $wallet): ?string;
/**
* Get a provider name
*
* @return string Provider name
*/
abstract public function name(): string;
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - type: first/oneoff/recurring
* - description: Operation description
+ * - methodId: Payment method
*
* @return array Provider payment/session data:
* - id: Operation identifier
* - redirectUrl
*/
abstract public function payment(Wallet $wallet, array $payment): ?array;
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
abstract public function webhook(): int;
/**
* Create a payment record in DB
*
* @param array $payment Payment information
* @param string $wallet_id Wallet ID
*
* @return \App\Payment Payment object
*/
protected function storePayment(array $payment, $wallet_id): Payment
{
$db_payment = new Payment();
$db_payment->id = $payment['id'];
$db_payment->description = $payment['description'] ?? '';
$db_payment->status = $payment['status'] ?? self::STATUS_OPEN;
$db_payment->amount = $payment['amount'] ?? 0;
$db_payment->type = $payment['type'];
$db_payment->wallet_id = $wallet_id;
$db_payment->provider = $this->name();
+ $db_payment->currency = $payment['currency'];
+ $db_payment->currency_amount = $payment['currency_amount'];
$db_payment->save();
return $db_payment;
}
+ /**
+ * Retrieve an exchange rate.
+ *
+ * @param string $sourceCurrency Currency from which to convert
+ * @param string $targetCurrency Currency to convert to
+ *
+ * @return float Exchange rate
+ */
+ protected function exchangeRate(string $sourceCurrency, string $targetCurrency): float
+ {
+ if (strcasecmp($sourceCurrency, $targetCurrency)) {
+ throw new \Exception("Currency conversion is not yet implemented.");
+ //FIXME Not yet implemented
+ }
+ return 1.0;
+ }
+
+ /**
+ * Convert a value from $sourceCurrency to $targetCurrency
+ *
+ * @param int $amount Amount in cents of $sourceCurrency
+ * @param string $sourceCurrency Currency from which to convert
+ * @param string $targetCurrency Currency to convert to
+ *
+ * @return int Exchanged amount in cents of $targetCurrency
+ */
+ protected function exchange(int $amount, string $sourceCurrency, string $targetCurrency): int
+ {
+ return intval(round($amount * $this->exchangeRate($sourceCurrency, $targetCurrency)));
+ }
+
/**
* Deduct an amount of pecunia from the wallet.
* Creates a payment and transaction records for the refund/chargeback operation.
*
* @param \App\Wallet $wallet A wallet object
* @param array $refund A refund or chargeback data (id, type, amount, description)
*
* @return void
*/
protected function storeRefund(Wallet $wallet, array $refund): void
{
if (empty($refund) || empty($refund['amount'])) {
return;
}
- $wallet->balance -= $refund['amount'];
+ // Preserve originally refunded amount
+ $refund['currency_amount'] = $refund['amount'];
+
+ // Convert amount to wallet currency
+ // TODO We should possibly be using the same exchange rate as for the original payment?
+ $amount = $this->exchange($refund['amount'], $refund['currency'], $wallet->currency);
+
+ $wallet->balance -= $amount;
$wallet->save();
if ($refund['type'] == self::TYPE_CHARGEBACK) {
$transaction_type = Transaction::WALLET_CHARGEBACK;
} else {
$transaction_type = Transaction::WALLET_REFUND;
}
Transaction::create([
'object_id' => $wallet->id,
'object_type' => Wallet::class,
'type' => $transaction_type,
- 'amount' => $refund['amount'] * -1,
+ 'amount' => $amount * -1,
'description' => $refund['description'] ?? '',
]);
$refund['status'] = self::STATUS_PAID;
- $refund['amount'] *= -1;
+ $refund['amount'] = -1 * $amount;
$this->storePayment($refund, $wallet->id);
}
+
+ /**
+ * List supported payment methods from this provider
+ *
+ * @param string $type The payment type for which we require a method (oneoff/recurring).
+ *
+ * @return array Array of array with available payment methods:
+ * - id: id of the method
+ * - name: User readable name of the payment method
+ * - minimumAmount: Minimum amount to be charged in cents
+ * - currency: Currency used for the method
+ * - exchangeRate: The projected exchange rate (actual rate is determined during payment)
+ * - icon: An icon (icon name) representing the method
+ */
+ abstract public function providerPaymentMethods($type): array;
+
+ /**
+ * Get a payment.
+ *
+ * @param string $paymentId Payment identifier
+ *
+ * @return array Payment information:
+ * - id: Payment identifier
+ * - status: Payment status
+ * - isCancelable: The payment can be canceled
+ * - checkoutUrl: The checkout url to complete the payment or null if none
+ */
+ abstract public function getPayment($paymentId): array;
+
+ /**
+ * Return an array of whitelisted payment methods with override values.
+ *
+ * @param string $type The payment type for which we require a method.
+ *
+ * @return array Array of methods
+ */
+ protected static function paymentMethodsWhitelist($type): array
+ {
+ switch ($type) {
+ case self::TYPE_ONEOFF:
+ return [
+ self::METHOD_CREDITCARD => [
+ 'id' => self::METHOD_CREDITCARD,
+ 'icon' => self::$paymentMethodIcons[self::METHOD_CREDITCARD]
+ ],
+ self::METHOD_PAYPAL => [
+ 'id' => self::METHOD_PAYPAL,
+ 'icon' => self::$paymentMethodIcons[self::METHOD_PAYPAL]
+ ],
+ // TODO Enable once we're ready to offer them
+ // self::METHOD_BANKTRANSFER => [
+ // 'id' => self::METHOD_BANKTRANSFER,
+ // 'icon' => self::$paymentMethodIcons[self::METHOD_BANKTRANSFER]
+ // ]
+ ];
+ case PaymentProvider::TYPE_RECURRING:
+ return [
+ self::METHOD_CREDITCARD => [
+ 'id' => self::METHOD_CREDITCARD,
+ 'icon' => self::$paymentMethodIcons[self::METHOD_CREDITCARD]
+ ]
+ ];
+ }
+
+ \Log::error("Unknown payment type: " . $type);
+ return [];
+ }
+
+ /**
+ * Return an array of whitelisted payment methods with override values.
+ *
+ * @param string $type The payment type for which we require a method.
+ *
+ * @return array Array of methods
+ */
+ private static function applyMethodWhitelist($type, $availableMethods): array
+ {
+ $methods = [];
+
+ // Use only whitelisted methods, and apply values from whitelist (overriding the backend)
+ $whitelistMethods = self::paymentMethodsWhitelist($type);
+ foreach ($whitelistMethods as $id => $whitelistMethod) {
+ if (array_key_exists($id, $availableMethods)) {
+ $methods[] = array_merge($availableMethods[$id], $whitelistMethod);
+ }
+ }
+
+ return $methods;
+ }
+
+ /**
+ * List supported payment methods for $wallet
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param string $type The payment type for which we require a method (oneoff/recurring).
+ *
+ * @return array Array of array with available payment methods:
+ * - id: id of the method
+ * - name: User readable name of the payment method
+ * - minimumAmount: Minimum amount to be charged in cents
+ * - currency: Currency used for the method
+ * - exchangeRate: The projected exchange rate (actual rate is determined during payment)
+ * - icon: An icon (icon name) representing the method
+ */
+ public static function paymentMethods(Wallet $wallet, $type): array
+ {
+ $providerName = self::providerName($wallet);
+
+ $cacheKey = "methods-" . $providerName . '-' . $type;
+
+ if ($methods = Cache::get($cacheKey)) {
+ \Log::debug("Using payment method cache" . var_export($methods, true));
+ return $methods;
+ }
+
+ $provider = PaymentProvider::factory($providerName);
+ $methods = self::applyMethodWhitelist($type, $provider->providerPaymentMethods($type));
+
+ Cache::put($cacheKey, $methods, now()->addHours(1));
+
+ return $methods;
+ }
}
diff --git a/src/database/migrations/2021_02_23_084157_payment_table_add_currency_columns.php b/src/database/migrations/2021_02_23_084157_payment_table_add_currency_columns.php
new file mode 100644
index 00000000..7e42e1f6
--- /dev/null
+++ b/src/database/migrations/2021_02_23_084157_payment_table_add_currency_columns.php
@@ -0,0 +1,55 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\DB;
+
+// phpcs:ignore
+class PaymentTableAddCurrencyColumns extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table(
+ 'payments',
+ function (Blueprint $table) {
+ $table->string('currency')->nullable();
+ $table->integer('currency_amount')->nullable();
+ }
+ );
+
+ DB::table('payments')->update([
+ 'currency' => 'CHF',
+ 'currency_amount' => DB::raw("`amount`")
+ ]);
+
+ Schema::table(
+ 'payments',
+ function (Blueprint $table) {
+ $table->string('currency')->nullable(false)->change();
+ $table->integer('currency_amount')->nullable(false)->change();
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table(
+ 'payments',
+ function (Blueprint $table) {
+ $table->dropColumn('currency');
+ $table->dropColumn('currency_amount');
+ }
+ );
+ }
+}
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
index 1caefa0b..f744f992 100644
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -1,477 +1,477 @@
/**
* First we will load all of this project's JavaScript dependencies which
* includes Vue and other libraries. It is a great starting point when
* building robust, powerful web applications using Vue and Laravel.
*/
require('./bootstrap')
import AppComponent from '../vue/App'
import MenuComponent from '../vue/Widgets/Menu'
import SupportForm from '../vue/Widgets/SupportForm'
import store from './store'
const loader = '<div class="app-loader"><div class="spinner-border" role="status"><span class="sr-only">Loading</span></div></div>'
let isLoading = 0
// Lock the UI with the 'loading...' element
const startLoading = () => {
isLoading++
let loading = $('#app > .app-loader').removeClass('fadeOut')
if (!loading.length) {
$('#app').append($(loader))
}
}
// Hide "loading" overlay
const stopLoading = () => {
if (isLoading > 0) {
$('#app > .app-loader').addClass('fadeOut')
isLoading--;
}
}
let loadingRoute
// Note: This has to be before the app is created
// Note: You cannot use app inside of the function
window.router.beforeEach((to, from, next) => {
// check if the route requires authentication and user is not logged in
if (to.matched.some(route => route.meta.requiresAuth) && !store.state.isLoggedIn) {
// remember the original request, to use after login
store.state.afterLogin = to;
// redirect to login page
next({ name: 'login' })
return
}
if (to.meta.loading) {
startLoading()
loadingRoute = to.name
}
next()
})
window.router.afterEach((to, from) => {
if (to.name && loadingRoute === to.name) {
stopLoading()
loadingRoute = null
}
// When changing a page remove old:
// - error page
// - modal backdrop
$('#error-page,.modal-backdrop.show').remove()
})
const app = new Vue({
el: '#app',
components: {
AppComponent,
MenuComponent,
},
store,
router: window.router,
data() {
return {
isAdmin: window.isAdmin,
appName: window.config['app.name'],
appUrl: window.config['app.url'],
themeDir: '/themes/' + window.config['app.theme']
}
},
methods: {
// Clear (bootstrap) form validation state
clearFormValidation(form) {
$(form).find('.is-invalid').removeClass('is-invalid')
$(form).find('.invalid-feedback').remove()
},
hasRoute(name) {
return this.$router.resolve({ name: name }).resolved.matched.length > 0
},
hasSKU(name) {
const authInfo = store.state.authInfo
return authInfo.statusInfo.skus && authInfo.statusInfo.skus.indexOf(name) != -1
},
isController(wallet_id) {
if (wallet_id && store.state.authInfo) {
let i
for (i = 0; i < store.state.authInfo.wallets.length; i++) {
if (wallet_id == store.state.authInfo.wallets[i].id) {
return true
}
}
for (i = 0; i < store.state.authInfo.accounts.length; i++) {
if (wallet_id == store.state.authInfo.accounts[i].id) {
return true
}
}
}
return false
},
// Set user state to "logged in"
loginUser(response, dashboard, update) {
if (!update) {
store.commit('logoutUser') // destroy old state data
store.commit('loginUser')
}
localStorage.setItem('token', response.access_token)
axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token
if (response.email) {
store.state.authInfo = response
}
if (dashboard !== false) {
this.$router.push(store.state.afterLogin || { name: 'dashboard' })
}
store.state.afterLogin = null
// Refresh the token before it expires
let timeout = response.expires_in || 0
// We'll refresh 60 seconds before the token expires
if (timeout > 60) {
timeout -= 60
}
// TODO: We probably should try a few times in case of an error
// TODO: We probably should prevent axios from doing any requests
// while the token is being refreshed
this.refreshTimeout = setTimeout(() => {
axios.post('/api/auth/refresh').then(response => {
this.loginUser(response.data, false, true)
})
}, timeout * 1000)
},
// Set user state to "not logged in"
logoutUser(redirect) {
store.commit('logoutUser')
localStorage.setItem('token', '')
delete axios.defaults.headers.common.Authorization
if (redirect !== false) {
this.$router.push({ name: 'login' })
}
clearTimeout(this.refreshTimeout)
},
logo(mode) {
let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png'
return `<img src="${src}" alt="${this.appName}">`
},
// Display "loading" overlay inside of the specified element
- addLoader(elem) {
- $(elem).css({position: 'relative'}).append($(loader).addClass('small'))
+ addLoader(elem, small = true) {
+ $(elem).css({position: 'relative'}).append(small ? $(loader).addClass('small') : $(loader))
},
// Remove loader element added in addLoader()
removeLoader(elem) {
$(elem).find('.app-loader').remove()
},
startLoading,
stopLoading,
isLoading() {
return isLoading > 0
},
errorPage(code, msg) {
// Until https://github.com/vuejs/vue-router/issues/977 is implemented
// we can't really use router to display error page as it has two side
// effects: it changes the URL and adds the error page to browser history.
// For now we'll be replacing current view with error page "manually".
const map = {
400: "Bad request",
401: "Unauthorized",
403: "Access denied",
404: "Not found",
405: "Method not allowed",
500: "Internal server error"
}
if (!msg) msg = map[code] || "Unknown Error"
const error_page = `<div id="error-page" class="error-page"><div class="code">${code}</div><div class="message">${msg}</div></div>`
$('#error-page').remove()
$('#app').append(error_page)
app.updateBodyClass('error')
},
errorHandler(error) {
this.stopLoading()
if (!error.response) {
// TODO: probably network connection error
} else if (error.response.status === 401) {
// Remember requested route to come back to it after log in
if (this.$route.meta.requiresAuth) {
store.state.afterLogin = this.$route
this.logoutUser()
} else {
this.logoutUser(false)
}
} else {
this.errorPage(error.response.status, error.response.statusText)
}
},
downloadFile(url) {
// TODO: This might not be a best way for big files as the content
// will be stored (temporarily) in browser memory
// TODO: This method does not show the download progress in the browser
// but it could be implemented in the UI, axios has 'progress' property
axios.get(url, { responseType: 'blob' })
.then(response => {
const link = document.createElement('a')
const contentDisposition = response.headers['content-disposition']
let filename = 'unknown'
if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+)"/);
if (match.length === 2) {
filename = match[1];
}
}
link.href = window.URL.createObjectURL(response.data)
link.download = filename
link.click()
})
},
price(price, currency) {
return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' })
},
priceLabel(cost, units = 1, discount) {
let index = ''
if (units < 0) {
units = 1
}
if (discount) {
cost = Math.floor(cost * ((100 - discount) / 100))
index = '\u00B9'
}
return this.price(cost * units) + '/month' + index
},
clickRecord(event) {
if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) {
let link = $(event.target).closest('tr').find('a')[0]
if (link) {
link.click()
}
}
},
domainStatusClass(domain) {
if (domain.isDeleted) {
return 'text-muted'
}
if (domain.isSuspended) {
return 'text-warning'
}
if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) {
return 'text-danger'
}
return 'text-success'
},
domainStatusText(domain) {
if (domain.isDeleted) {
return 'Deleted'
}
if (domain.isSuspended) {
return 'Suspended'
}
if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) {
return 'Not Ready'
}
return 'Active'
},
pageName(path) {
let page = this.$route.path
// check if it is a "menu page", find the page name
// otherwise we'll use the real path as page name
window.config.menu.every(item => {
if (item.location == page && item.page) {
page = item.page
return false
}
})
page = page.replace(/^\//, '')
return page ? page : '404'
},
supportDialog(container) {
let dialog = $('#support-dialog')
// FIXME: Find a nicer way of doing this
if (!dialog.length) {
let form = new Vue(SupportForm)
form.$mount($('<div>').appendTo(container)[0])
form.$root = this
form.$toast = this.$toast
dialog = $(form.$el)
}
dialog.on('shown.bs.modal', () => {
dialog.find('input').first().focus()
}).modal()
},
userStatusClass(user) {
if (user.isDeleted) {
return 'text-muted'
}
if (user.isSuspended) {
return 'text-warning'
}
if (!user.isImapReady || !user.isLdapReady) {
return 'text-danger'
}
return 'text-success'
},
userStatusText(user) {
if (user.isDeleted) {
return 'Deleted'
}
if (user.isSuspended) {
return 'Suspended'
}
if (!user.isImapReady || !user.isLdapReady) {
return 'Not Ready'
}
return 'Active'
},
updateBodyClass(name) {
// Add 'class' attribute to the body, different for each page
// so, we can apply page-specific styles
let className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '')
$(document.body).removeClass().addClass(className)
}
}
})
// Add a axios request interceptor
window.axios.interceptors.request.use(
config => {
// This is the only way I found to change configuration options
// on a running application. We need this for browser testing.
config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider
return config
},
error => {
// Do something with request error
return Promise.reject(error)
}
)
// Add a axios response interceptor for general/validation error handler
window.axios.interceptors.response.use(
response => {
if (response.config.onFinish) {
response.config.onFinish()
}
return response
},
error => {
let error_msg
let status = error.response ? error.response.status : 200
// Do not display the error in a toast message, pass the error as-is
if (error.config.ignoreErrors) {
return Promise.reject(error)
}
if (error.config.onFinish) {
error.config.onFinish()
}
if (error.response && status == 422) {
error_msg = "Form validation error"
const modal = $('div.modal.show')
$(modal.length ? modal : 'form').each((i, form) => {
form = $(form)
$.each(error.response.data.errors || {}, (idx, msg) => {
const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx
let input = form.find('#' + input_name)
if (!input.length) {
input = form.find('[name="' + input_name + '"]');
}
if (input.length) {
// Create an error message\
// API responses can use a string, array or object
let msg_text = ''
if ($.type(msg) !== 'string') {
$.each(msg, (index, str) => {
msg_text += str + ' '
})
}
else {
msg_text = msg
}
let feedback = $('<div class="invalid-feedback">').text(msg_text)
if (input.is('.list-input')) {
// List input widget
input.children(':not(:first-child)').each((index, element) => {
if (msg[index]) {
$(element).find('input').addClass('is-invalid')
}
})
input.addClass('is-invalid').next('.invalid-feedback').remove()
input.after(feedback)
}
else {
// Standard form element
input.addClass('is-invalid')
input.parent().find('.invalid-feedback').remove()
input.parent().append(feedback)
}
}
})
form.find('.is-invalid:not(.listinput-widget)').first().focus()
})
}
else if (error.response && error.response.data) {
error_msg = error.response.data.message
}
else {
error_msg = error.request ? error.request.statusText : error.message
}
app.$toast.error(error_msg || "Server Error")
// Pass the error as-is
return Promise.reject(error)
}
)
diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js
index aadaba73..93297fa2 100644
--- a/src/resources/js/fontawesome.js
+++ b/src/resources/js/fontawesome.js
@@ -1,59 +1,66 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
//import { } from '@fortawesome/free-brands-svg-icons'
import {
faCheckSquare,
faCreditCard,
faSquare,
} from '@fortawesome/free-regular-svg-icons'
import {
faCheck,
faCheckCircle,
faComments,
faDownload,
faEnvelope,
faGlobe,
+ faUniversity,
faExclamationCircle,
faInfoCircle,
faLock,
faKey,
faPlus,
faSearch,
faSignInAlt,
faSyncAlt,
faTrashAlt,
faUser,
faUserCog,
faUsers,
faWallet
} from '@fortawesome/free-solid-svg-icons'
+import {
+ faPaypal
+} from '@fortawesome/free-brands-svg-icons'
+
// Register only these icons we need
library.add(
faCheck,
faCheckCircle,
faCheckSquare,
faComments,
faCreditCard,
+ faPaypal,
+ faUniversity,
faDownload,
faEnvelope,
faExclamationCircle,
faGlobe,
faInfoCircle,
faLock,
faKey,
faPlus,
faSearch,
faSignInAlt,
faSquare,
faSyncAlt,
faTrashAlt,
faUser,
faUserCog,
faUsers,
faWallet
)
export default FontAwesomeIcon
diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss
index b3944964..c5b8c840 100644
--- a/src/resources/themes/app.scss
+++ b/src/resources/themes/app.scss
@@ -1,431 +1,452 @@
html,
body,
body > .outer-container {
height: 100%;
}
#app {
display: flex;
flex-direction: column;
min-height: 100%;
overflow: hidden;
& > nav {
flex-shrink: 0;
z-index: 12;
}
& > div.container {
flex-grow: 1;
margin-top: 2rem;
margin-bottom: 2rem;
}
& > .filler {
flex-grow: 1;
}
& > div.container + .filler {
display: none;
}
}
.error-page {
position: absolute;
top: 0;
height: 100%;
width: 100%;
align-items: center;
display: flex;
justify-content: center;
color: #636b6f;
z-index: 10;
background: white;
.code {
text-align: right;
border-right: 2px solid;
font-size: 26px;
padding: 0 15px;
}
.message {
font-size: 18px;
padding: 0 15px;
}
}
.app-loader {
background-color: $body-bg;
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 8;
.spinner-border {
width: 120px;
height: 120px;
border-width: 15px;
color: #b2aa99;
}
&.small .spinner-border {
width: 25px;
height: 25px;
border-width: 3px;
}
&.fadeOut {
visibility: hidden;
opacity: 0;
transition: visibility 300ms linear, opacity 300ms linear;
}
}
pre {
margin: 1rem 0;
padding: 1rem;
background-color: $menu-bg-color;
}
.card-title {
font-size: 1.2rem;
font-weight: bold;
}
tfoot.table-fake-body {
background-color: #f8f8f8;
color: grey;
text-align: center;
td {
vertical-align: middle;
height: 8em;
border: 0;
}
tbody:not(:empty) + & {
display: none;
}
}
table {
td.buttons,
td.email,
td.price,
td.datetime,
td.selection {
width: 1%;
white-space: nowrap;
}
th.price,
td.price {
width: 1%;
text-align: right;
white-space: nowrap;
}
&.form-list {
margin: 0;
td {
border: 0;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
button {
line-height: 1;
}
}
.btn-action {
line-height: 1;
padding: 0;
}
}
.list-details {
min-height: 1em;
& > ul {
margin: 0;
padding-left: 1.2em;
}
}
.plan-selector {
.plan-header {
display: flex;
}
.plan-ico {
margin:auto;
font-size: 3.8rem;
color: #f1a539;
border: 3px solid #f1a539;
width: 6rem;
height: 6rem;
border-radius: 50%;
}
}
.status-message {
display: flex;
align-items: center;
justify-content: center;
.app-loader {
width: auto;
position: initial;
.spinner-border {
color: $body-color;
}
}
svg {
font-size: 1.5em;
}
:first-child {
margin-right: 0.4em;
}
}
.form-separator {
position: relative;
margin: 1em 0;
display: flex;
justify-content: center;
hr {
border-color: #999;
margin: 0;
position: absolute;
top: 0.75em;
width: 100%;
}
span {
background: #fff;
padding: 0 1em;
z-index: 1;
}
}
#status-box {
background-color: lighten($green, 35);
.progress {
background-color: #fff;
height: 10px;
}
.progress-label {
font-size: 0.9em;
}
.progress-bar {
background-color: $green;
}
&.process-failed {
background-color: lighten($orange, 30);
.progress-bar {
background-color: $red;
}
}
}
@keyframes blinker {
50% {
opacity: 0;
}
}
.blinker {
animation: blinker 750ms step-start infinite;
}
#dashboard-nav {
display: flex;
flex-wrap: wrap;
justify-content: center;
& > a {
padding: 1rem;
text-align: center;
white-space: nowrap;
margin: 0.25rem;
text-decoration: none;
width: 150px;
&.disabled {
pointer-events: none;
opacity: 0.6;
}
.badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
}
svg {
width: 6rem;
height: 6rem;
margin: auto;
}
}
+#payment-method-selection {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+
+ & > a {
+ padding: 1rem;
+ text-align: center;
+ white-space: nowrap;
+ margin: 0.25rem;
+ text-decoration: none;
+ width: 150px;
+ }
+
+ svg {
+ width: 6rem;
+ height: 6rem;
+ margin: auto;
+ }
+}
+
#logon-form {
flex-basis: auto; // Bootstrap issue? See logon page with width < 992
}
#logon-form-footer {
a:not(:first-child) {
margin-left: 2em;
}
}
// Various improvements for mobile
@include media-breakpoint-down(sm) {
.card,
.card-footer {
border: 0;
}
.card-body {
padding: 0.5rem 0;
}
.form-group {
margin-bottom: 0.5rem;
}
.nav-tabs {
flex-wrap: nowrap;
overflow-x: auto;
.nav-link {
white-space: nowrap;
padding: 0.5rem 0.75rem;
}
}
.tab-content {
margin-top: 0.5rem;
}
.col-form-label {
color: #666;
font-size: 95%;
}
.form-group.plaintext .col-form-label {
padding-bottom: 0;
}
form.read-only.short label {
width: 35%;
& + * {
width: 65%;
}
}
#app > div.container {
margin-bottom: 1rem;
margin-top: 1rem;
max-width: 100%;
}
#header-menu-navbar {
padding: 0;
}
#dashboard-nav > a {
width: 135px;
}
.table-sm:not(.form-list) {
tbody td {
padding: 0.75rem 0.5rem;
svg {
vertical-align: -0.175em;
}
& > svg {
font-size: 125%;
margin-right: 0.25rem;
}
}
}
.table.transactions {
thead {
display: none;
}
tbody {
tr {
position: relative;
display: flex;
flex-wrap: wrap;
}
td {
width: auto;
border: 0;
padding: 0.5rem;
&.datetime {
width: 50%;
padding-left: 0;
}
&.description {
order: 3;
width: 100%;
border-bottom: 1px solid $border-color;
color: $secondary;
padding: 0 1.5em 0.5rem 0;
margin-top: -0.25em;
}
&.selection {
position: absolute;
right: 0;
border: 0;
top: 1.7em;
padding-right: 0;
}
&.price {
width: 50%;
padding-right: 0;
}
&.email {
display: none;
}
}
}
}
}
diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue
index ed31fe1d..fc71405c 100644
--- a/src/resources/vue/Wallet.vue
+++ b/src/resources/vue/Wallet.vue
@@ -1,372 +1,473 @@
<template>
<div class="container" dusk="wallet-component">
<div v-if="wallet.id" id="wallet" class="card">
<div class="card-body">
<div class="card-title">Account balance <span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'">{{ $root.price(wallet.balance, wallet.currency) }}</span></div>
<div class="card-text">
<p v-if="wallet.notice" id="wallet-notice">{{ wallet.notice }}</p>
- <p>Add credit to your account or setup an automatic payment by using the button below.</p>
- <button type="button" class="btn btn-primary" @click="paymentDialog()">Add credit</button>
+
+ <div v-if="showPendingPayments" class="alert alert-warning">
+ You have payments that are still in progress. See the "Pending Payments" tab below.
+ </div>
+ <p>
+ <button type="button" class="btn btn-primary" @click="paymentMethodForm('manual')">Add credit</button>
+ </p>
+ <div id="mandate-form" v-if="!mandate.isValid && !mandate.isPending">
+ <template v-if="mandate.id && !mandate.isValid">
+ <div class="alert alert-danger">
+ The setup of automatic payments failed. Restart the process to enable automatic top-ups.
+ </div>
+ <button type="button" class="btn btn-danger" @click="autoPaymentDelete">Cancel auto-payment</button>
+ </template>
+ <button type="button" class="btn btn-primary" @click="paymentMethodForm('auto')">Set up auto-payment</button>
+ </div>
+ <div id="mandate-info" v-else>
+ <div v-if="mandate.isDisabled" class="disabled-mandate alert alert-danger">
+ The configured auto-payment has been disabled. Top up your wallet or
+ raise the auto-payment amount.
+ </div>
+ <template v-else>
+ <p>
+ Auto-payment is <b>set</b> to fill up your account by <b>{{ mandate.amount }} CHF</b>
+ every time your account balance gets under <b>{{ mandate.balance }} CHF</b>.
+ </p>
+ <p>
+ Method of payment: {{ mandate.method }}
+ </p>
+ </template>
+ <div v-if="mandate.isPending" class="alert alert-warning">
+ The setup of the automatic payment is still in progress.
+ </div>
+ <p>
+ <button type="button" class="btn btn-danger" @click="autoPaymentDelete">Cancel auto-payment</button>
+ <button type="button" class="btn btn-primary" @click="autoPaymentChange">Change auto-payment</button>
+ </p>
+ </div>
</div>
</div>
</div>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-receipts" href="#wallet-receipts" role="tab" aria-controls="wallet-receipts" aria-selected="true">
Receipts
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-history" href="#wallet-history" role="tab" aria-controls="wallet-history" aria-selected="false">
History
</a>
</li>
+ <li v-if="showPendingPayments" class="nav-item">
+ <a class="nav-link" id="tab-payments" href="#wallet-payments" role="tab" aria-controls="wallet-payments" aria-selected="false">
+ Pending Payments
+ </a>
+ </li>
</ul>
<div class="tab-content">
- <div class="tab-pane show active" id="wallet-receipts" role="tabpanel" aria-labelledby="tab-receipts">
+ <div class="tab-pane active" id="wallet-receipts" role="tabpanel" aria-labelledby="tab-receipts">
<div class="card-body">
<div class="card-text">
<p v-if="receipts.length">
Here you can download receipts (in PDF format) for payments in specified period.
Select the period and press the Download button.
</p>
<div v-if="receipts.length" class="input-group">
<select id="receipt-id" class="form-control">
<option v-for="(receipt, index) in receipts" :key="index" :value="receipt">{{ receipt }}</option>
</select>
<div class="input-group-append">
<button type="button" class="btn btn-secondary" @click="receiptDownload">
<svg-icon icon="download"></svg-icon> Download
</button>
</div>
</div>
<p v-if="!receipts.length">
There are no receipts for payments in this account. Please, note that you can download
receipts after the month ends.
</p>
</div>
</div>
</div>
- <div class="tab-pane show" id="wallet-history" role="tabpanel" aria-labelledby="tab-history">
+ <div class="tab-pane" id="wallet-history" role="tabpanel" aria-labelledby="tab-history">
<div class="card-body">
<transaction-log v-if="walletId && loadTransactions" class="card-text" :wallet-id="walletId"></transaction-log>
</div>
</div>
+ <div class="tab-pane" id="wallet-payments" role="tabpanel" aria-labelledby="tab-payments">
+ <div class="card-body">
+ <payment-log v-if="walletId && loadPayments" class="card-text" :wallet-id="walletId"></payment-log>
+ </div>
+ </div>
</div>
<div id="payment-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ paymentDialogTitle }}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
- <div id="payment" v-if="paymentForm == 'init'">
+ <div id="payment-method" v-if="paymentForm == 'method'">
+ <form data-validation-prefix="mandate_">
+ <div id="payment-method-selection">
+ <a :id="method.id" v-for="method in paymentMethods" :key="method.id" @click="selectPaymentMethod(method)" href="#" class="card link-profile">
+ <svg-icon v-if="method.icon" :icon="[method.icon.prefix, method.icon.name]" />
+ <img v-if="method.image" v-bind:src="method.image" />
+ <span class="name">{{ method.name }}</span>
+ </a>
+ </div>
+ </form>
+ </div>
+ <div id="manual-payment" v-if="paymentForm == 'manual'">
+ <p v-if="wallet.currency != selectedPaymentMethod.currency">
+ Here is how it works: You specify the amount by which you want to to up your wallet in {{ wallet.currency }}.
+ We will then convert this to {{ selectedPaymentMethod.currency }}, and on the next page you will be provided with the bank-details
+ to transfer the amount in {{ selectedPaymentMethod.currency }}.
+ </p>
+ <p v-if="selectedPaymentMethod.id == 'banktransfer'">
+ Please note that a bank transfer can take several days to complete.
+ </p>
<p>Choose the amount by which you want to top up your wallet.</p>
<form id="payment-form" @submit.prevent="payment">
<div class="form-group">
<div class="input-group">
<input type="text" class="form-control" id="amount" v-model="amount" required>
<span class="input-group-append">
<span class="input-group-text">{{ wallet.currency }}</span>
</span>
</div>
</div>
- <div class="w-100 text-center">
- <button type="submit" class="btn btn-primary">
- <svg-icon :icon="['far', 'credit-card']"></svg-icon> Continue
- </button>
+ <div v-if="wallet.currency != selectedPaymentMethod.currency && !isNaN(amount)" class="alert alert-warning">
+ You will be charged for {{ $root.price(amount * selectedPaymentMethod.exchangeRate * 100, selectedPaymentMethod.currency) }}
</div>
</form>
- <div class="form-separator"><hr><span>or</span></div>
- <div id="mandate-form" v-if="!mandate.isValid && !mandate.isPending">
- <p>Add auto-payment, so you never run out.</p>
- <div v-if="mandate.id && !mandate.isValid" class="alert alert-danger">
- The setup of automatic payments failed. Restart the process to enable automatic top-ups.
- </div>
- <div class="w-100 text-center">
- <button type="button" class="btn btn-primary" @click="autoPaymentForm">Set up auto-payment</button>
- </div>
- </div>
- <div id="mandate-info" v-else>
- <p>
- Auto-payment is set to fill up your account by <b>{{ mandate.amount }} CHF</b>
- every time your account balance gets under <b>{{ mandate.balance }} CHF</b>.
- You will be charged via {{ mandate.method }}.
- </p>
- <div v-if="mandate.isPending" class="alert alert-warning">
- The setup of the automatic payment is still in progress.
- </div>
- <div v-else-if="mandate.isDisabled" class="disabled-mandate alert alert-danger">
- The configured auto-payment has been disabled. Top up your wallet or
- raise the auto-payment amount.
- </div>
- <p>You can cancel or change the auto-payment at any time.</p>
- <div class="form-group d-flex justify-content-around">
- <button type="button" class="btn btn-danger" @click="autoPaymentDelete">Cancel auto-payment</button>
- <button type="button" class="btn btn-primary" @click="autoPaymentChange">Change auto-payment</button>
- </div>
- </div>
</div>
<div id="auto-payment" v-if="paymentForm == 'auto'">
<form data-validation-prefix="mandate_">
<p>
Here is how it works: Every time your account runs low,
we will charge your preferred payment method for an amount you choose.
You can cancel or change the auto-payment option at any time.
</p>
<div class="form-group row">
<label for="mandate_amount" class="col-sm-6 col-form-label">Fill up by</label>
<div class="input-group col-sm-6">
<input type="text" class="form-control" id="mandate_amount" v-model="mandate.amount" required>
<span class="input-group-append">
<span class="input-group-text">{{ wallet.currency }}</span>
</span>
</div>
</div>
<div class="form-group row">
<label for="mandate_balance" class="col-sm-6 col-form-label">when account balance is below</label>
<div class="col-sm-6">
<div class="input-group">
<input type="text" class="form-control" id="mandate_balance" v-model="mandate.balance" required>
<span class="input-group-append">
<span class="input-group-text">{{ wallet.currency }}</span>
</span>
</div>
</div>
</div>
<p v-if="!mandate.isValid">
Next, you will be redirected to the checkout page, where you can provide
your credit card details.
</p>
<div v-if="mandate.isValid && mandate.isDisabled" class="disabled-mandate alert alert-danger">
The auto-payment is disabled. Immediately after you submit new settings we'll
enable it and attempt to top up your wallet.
</div>
</form>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary modal-action"
v-if="paymentForm == 'auto' && (mandate.isValid || mandate.isPending)"
@click="autoPayment"
>
<svg-icon icon="check"></svg-icon> Submit
</button>
<button type="button" class="btn btn-primary modal-action"
v-if="paymentForm == 'auto' && !mandate.isValid && !mandate.isPending"
@click="autoPayment"
>
- <svg-icon :icon="['far', 'credit-card']"></svg-icon> Continue
+ <svg-icon icon="check"></svg-icon> Continue
+ </button>
+ <button type="button" class="btn btn-primary modal-action"
+ v-if="paymentForm == 'manual'"
+ @click="payment"
+ >
+ <svg-icon icon="check"></svg-icon> Continue
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import TransactionLog from './Widgets/TransactionLog'
+ import PaymentLog from './Widgets/PaymentLog'
export default {
components: {
- TransactionLog
+ TransactionLog,
+ PaymentLog
},
data() {
return {
amount: '',
- mandate: { amount: 10, balance: 0 },
+ mandate: { amount: 10, balance: 0, method: null },
paymentDialogTitle: null,
- paymentForm: 'init',
+ paymentForm: null,
+ nextForm: null,
receipts: [],
stripe: null,
loadTransactions: false,
+ loadPayments: false,
+ showPendingPayments: false,
wallet: {},
- walletId: null
+ walletId: null,
+ paymentMethods: [],
+ selectedPaymentMethod: null
}
},
mounted() {
$('#wallet button').focus()
this.walletId = this.$store.state.authInfo.wallets[0].id
this.$root.startLoading()
axios.get('/api/v4/wallets/' + this.walletId)
.then(response => {
this.$root.stopLoading()
this.wallet = response.data
const receiptsTab = $('#wallet-receipts')
this.$root.addLoader(receiptsTab)
axios.get('/api/v4/wallets/' + this.walletId + '/receipts')
.then(response => {
this.$root.removeLoader(receiptsTab)
this.receipts = response.data.list
})
.catch(error => {
this.$root.removeLoader(receiptsTab)
})
if (this.wallet.provider == 'stripe') {
this.stripeInit()
}
})
.catch(this.$root.errorHandler)
+ this.loadMandate()
+
+ axios.get('/api/v4/payments/has-pending')
+ .then(response => {
+ this.showPendingPayments = response.data.hasPending
+ })
+
+ },
+ updated() {
$(this.$el).find('ul.nav-tabs a').on('click', e => {
e.preventDefault()
$(e.target).tab('show')
if ($(e.target).is('#tab-history')) {
this.loadTransactions = true
}
+ if ($(e.target).is('#tab-payments')) {
+ this.loadPayments = true
+ }
})
},
methods: {
- paymentDialog() {
- const dialog = $('#payment-dialog')
+ loadMandate() {
const mandate_form = $('#mandate-form')
this.$root.removeLoader(mandate_form)
if (!this.mandate.id || this.mandate.isPending) {
this.$root.addLoader(mandate_form)
axios.get('/api/v4/payments/mandate')
.then(response => {
this.$root.removeLoader(mandate_form)
this.mandate = response.data
})
.catch(error => {
this.$root.removeLoader(mandate_form)
})
}
+ },
+ selectPaymentMethod(method) {
+ this.formLock = false
+
+ this.selectedPaymentMethod = method
+ this.paymentForm = this.nextForm
this.formLock = false
- this.paymentForm = 'init'
- this.paymentDialogTitle = 'Top up your wallet'
- this.dialog = dialog.on('shown.bs.modal', () => {
- dialog.find('#amount').focus()
- }).modal()
+ setTimeout(() => {
+ this.dialog.find('#mandate_amount').focus()
+ this.dialog.find('#amount').focus()
+ }, 10)
},
payment() {
if (this.formLock) {
return
}
// Lock the form to prevent from double submission
this.formLock = true
let onFinish = () => { this.formLock = false }
this.$root.clearFormValidation($('#payment-form'))
- axios.post('/api/v4/payments', {amount: this.amount}, { onFinish })
+ axios.post('/api/v4/payments', {amount: this.amount, methodId: this.selectedPaymentMethod.id, currency: this.selectedPaymentMethod.currency}, { onFinish })
.then(response => {
if (response.data.redirectUrl) {
location.href = response.data.redirectUrl
} else {
this.stripeCheckout(response.data)
}
})
},
autoPayment() {
if (this.formLock) {
return
}
// Lock the form to prevent from double submission
this.formLock = true
let onFinish = () => { this.formLock = false }
const method = this.mandate.id && (this.mandate.isValid || this.mandate.isPending) ? 'put' : 'post'
- const post = {
+ let post = {
amount: this.mandate.amount,
- balance: this.mandate.balance
+ balance: this.mandate.balance,
+ }
+
+ // Modifications can't change the method of payment
+ if (this.selectedPaymentMethod) {
+ post['methodId'] = this.selectedPaymentMethod.id;
+ post['currency'] = this.selectedPaymentMethod.currency;
}
this.$root.clearFormValidation($('#auto-payment form'))
axios[method]('/api/v4/payments/mandate', post, { onFinish })
.then(response => {
if (method == 'post') {
this.mandate.id = null
// a new mandate, redirect to the chackout page
if (response.data.redirectUrl) {
location.href = response.data.redirectUrl
} else if (response.data.id) {
this.stripeCheckout(response.data)
}
} else {
// an update
if (response.data.status == 'success') {
this.dialog.modal('hide');
this.mandate = response.data
this.$toast.success(response.data.message)
}
}
})
},
autoPaymentChange(event) {
this.autoPaymentForm(event, 'Update auto-payment')
},
autoPaymentDelete() {
axios.delete('/api/v4/payments/mandate')
.then(response => {
this.mandate = { amount: 10, balance: 0 }
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
}
})
},
+
+ paymentMethodForm(nextForm) {
+ const dialog = $('#payment-dialog')
+ this.formLock = false
+ this.paymentMethods = []
+
+ this.paymentForm = 'method'
+ this.nextForm = nextForm
+ if (nextForm == 'auto') {
+ this.paymentDialogTitle = 'Add auto-payment'
+ } else {
+ this.paymentDialogTitle = 'Top up your wallet'
+ }
+
+ const methods = $('#payment-method')
+ this.$root.addLoader(methods, false)
+ axios.get('/api/v4/payments/methods', {params: {type: nextForm == 'manual' ? 'oneoff' : 'recurring'}})
+ .then(response => {
+ this.$root.removeLoader(methods)
+ this.paymentMethods = response.data
+ })
+ .catch(this.$root.errorHandler)
+
+ this.dialog = dialog.on('shown.bs.modal', () => {}).modal()
+ },
autoPaymentForm(event, title) {
+ const dialog = $('#payment-dialog')
+
this.paymentForm = 'auto'
- this.paymentDialogTitle = title || 'Add auto-payment'
+ this.paymentDialogTitle = title
this.formLock = false
+
+ this.dialog = dialog.on('shown.bs.modal', () => {
+ dialog.find('#mandate_amount').focus()
+ }).modal()
+
setTimeout(() => { this.dialog.find('#mandate_amount').focus()}, 10)
},
receiptDownload() {
const receipt = $('#receipt-id').val()
this.$root.downloadFile('/api/v4/wallets/' + this.walletId + '/receipts/' + receipt)
},
stripeInit() {
let script = $('#stripe-script')
if (!script.length) {
script = document.createElement('script')
script.onload = () => {
this.stripe = Stripe(window.config.stripePK)
}
script.id = 'stripe-script'
script.src = 'https://js.stripe.com/v3/'
document.getElementsByTagName('head')[0].appendChild(script)
} else {
this.stripe = Stripe(window.config.stripePK)
}
},
stripeCheckout(data) {
if (!this.stripe) {
return
}
this.stripe.redirectToCheckout({
sessionId: data.id
}).then(result => {
// If it fails due to a browser or network error,
// display the localized error message to the user
if (result.error) {
this.$toast.error(result.error.message)
}
})
}
}
}
</script>
diff --git a/src/resources/vue/Widgets/PaymentLog.vue b/src/resources/vue/Widgets/PaymentLog.vue
new file mode 100644
index 00000000..4402db39
--- /dev/null
+++ b/src/resources/vue/Widgets/PaymentLog.vue
@@ -0,0 +1,76 @@
+<template>
+ <div>
+ <table class="table table-sm m-0 payments">
+ <thead class="thead-light">
+ <tr>
+ <th scope="col">Date</th>
+ <th scope="col">Description</th>
+ <th scope="col"></th>
+ <th scope="col" class="price">Amount</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="payment in payments" :id="'log' + payment.id" :key="payment.id">
+ <td class="datetime">{{ payment.createdAt }}</td>
+ <td class="description">{{ payment.description }}</td>
+ <td><a v-if="payment.checkoutUrl" v-bind:href="payment.checkoutUrl">Details</a></td>
+ <td class="price text-success">{{ amount(payment) }}</td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td colspan="4">There are no pending payments for this account.</td>
+ </tr>
+ </tfoot>
+ </table>
+ <div class="text-center p-3" id="payments-loader" v-if="hasMore">
+ <button class="btn btn-secondary" @click="loadLog(true)">Load more</button>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ props: {
+ },
+ data() {
+ return {
+ payments: [],
+ hasMore: false,
+ page: 1
+ }
+ },
+ mounted() {
+ this.loadLog()
+ },
+ methods: {
+ loadLog(more) {
+ let loader = $(this.$el)
+ let param = ''
+
+ if (more) {
+ param = '?page=' + (this.page + 1)
+ loader = $('#payments-loader')
+ }
+
+ this.$root.addLoader(loader)
+ axios.get('/api/v4/payments/pending' + param)
+ .then(response => {
+ this.$root.removeLoader(loader)
+ // Note: In Vue we can't just use .concat()
+ for (let i in response.data.list) {
+ this.$set(this.payments, this.payments.length, response.data.list[i])
+ }
+ this.hasMore = response.data.hasMore
+ this.page = response.data.page || 1
+ })
+ .catch(error => {
+ this.$root.removeLoader(loader)
+ })
+ },
+ amount(payment) {
+ return this.$root.price(payment.amount)
+ }
+ }
+ }
+</script>
diff --git a/src/routes/api.php b/src/routes/api.php
index 86c4eaeb..b9c906dc 100644
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -1,162 +1,166 @@
<?php
use Illuminate\Http\Request;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
$prefix = \trim(\parse_url(\config('app.url'), PHP_URL_PATH), '/') . '/';
Route::group(
[
'middleware' => 'api',
'prefix' => $prefix . 'api/auth'
],
function ($router) {
Route::post('login', 'API\AuthController@login');
Route::group(
['middleware' => 'auth:api'],
function ($router) {
Route::get('info', 'API\AuthController@info');
Route::post('logout', 'API\AuthController@logout');
Route::post('refresh', 'API\AuthController@refresh');
}
);
}
);
Route::group(
[
'domain' => \config('app.domain'),
'middleware' => 'api',
'prefix' => $prefix . 'api/auth'
],
function ($router) {
Route::post('password-reset/init', 'API\PasswordResetController@init');
Route::post('password-reset/verify', 'API\PasswordResetController@verify');
Route::post('password-reset', 'API\PasswordResetController@reset');
Route::get('signup/plans', 'API\SignupController@plans');
Route::post('signup/init', 'API\SignupController@init');
Route::post('signup/verify', 'API\SignupController@verify');
Route::post('signup', 'API\SignupController@signup');
}
);
Route::group(
[
'domain' => \config('app.domain'),
'middleware' => 'auth:api',
'prefix' => $prefix . 'api/v4'
],
function () {
Route::apiResource('domains', API\V4\DomainsController::class);
Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm');
Route::get('domains/{id}/status', 'API\V4\DomainsController@status');
Route::apiResource('entitlements', API\V4\EntitlementsController::class);
Route::apiResource('packages', API\V4\PackagesController::class);
Route::apiResource('skus', API\V4\SkusController::class);
Route::apiResource('users', API\V4\UsersController::class);
Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus');
Route::get('users/{id}/status', 'API\V4\UsersController@status');
Route::apiResource('wallets', API\V4\WalletsController::class);
Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions');
Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts');
Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload');
Route::post('payments', 'API\V4\PaymentsController@store');
+ //Route::delete('payments', 'API\V4\PaymentsController@cancel');
Route::get('payments/mandate', 'API\V4\PaymentsController@mandate');
Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate');
Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate');
Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete');
+ Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods');
+ Route::get('payments/pending', 'API\V4\PaymentsController@payments');
+ Route::get('payments/has-pending', 'API\V4\PaymentsController@hasPayments');
Route::get('openvidu/rooms', 'API\V4\OpenViduController@index');
Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom');
Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig');
// FIXME: I'm not sure about this one, should we use DELETE request maybe?
Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection');
Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection');
Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest');
Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest');
}
);
// Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group
Route::group(
[
'domain' => \config('app.domain'),
'prefix' => $prefix . 'api/v4'
],
function () {
Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom');
Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection');
// FIXME: I'm not sure about this one, should we use DELETE request maybe?
Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection');
Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection');
Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest');
Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest');
}
);
Route::group(
[
'domain' => \config('app.domain'),
'middleware' => 'api',
'prefix' => $prefix . 'api/v4'
],
function ($router) {
Route::post('support/request', 'API\V4\SupportController@request');
}
);
Route::group(
[
'domain' => \config('app.domain'),
'prefix' => $prefix . 'api/webhooks',
],
function () {
Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook');
Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook');
}
);
Route::group(
[
'domain' => 'admin.' . \config('app.domain'),
'middleware' => ['auth:api', 'admin'],
'prefix' => $prefix . 'api/v4',
],
function () {
Route::apiResource('domains', API\V4\Admin\DomainsController::class);
Route::get('domains/{id}/confirm', 'API\V4\Admin\DomainsController@confirm');
Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend');
Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend');
Route::apiResource('entitlements', API\V4\Admin\EntitlementsController::class);
Route::apiResource('packages', API\V4\Admin\PackagesController::class);
Route::apiResource('skus', API\V4\Admin\SkusController::class);
Route::apiResource('users', API\V4\Admin\UsersController::class);
Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA');
Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus');
Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend');
Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend');
Route::apiResource('wallets', API\V4\Admin\WalletsController::class);
Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff');
Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions');
Route::apiResource('discounts', API\V4\Admin\DiscountsController::class);
Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart');
}
);
diff --git a/src/tests/Browser/PaymentMollieTest.php b/src/tests/Browser/PaymentMollieTest.php
index c12b759b..27018d3f 100644
--- a/src/tests/Browser/PaymentMollieTest.php
+++ b/src/tests/Browser/PaymentMollieTest.php
@@ -1,315 +1,313 @@
<?php
namespace Tests\Browser;
use App\Providers\PaymentProvider;
use App\Wallet;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\PaymentMollie;
use Tests\Browser\Pages\Wallet as WalletPage;
use Tests\TestCaseDusk;
class PaymentMollieTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('payment-test@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('payment-test@kolabnow.com');
parent::tearDown();
}
/**
* Test the payment process
*
* @group mollie
*/
public function testPayment(): void
{
$user = $this->getTestUser('payment-test@kolabnow.com', [
'password' => 'simple123',
]);
$this->browse(function (Browser $browser) use ($user) {
$browser->visit(new Home())
->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie'])
->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
->assertSeeIn('@main button', 'Add credit')
->click('@main button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Top up your wallet')
+ ->waitFor('#payment-method-selection #creditcard')
+ ->waitFor('#payment-method-selection #paypal')
+ ->assertMissing('#payment-method-selection #banktransfer')
+ ->click('#creditcard');
+ })
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Top up your wallet')
->assertFocused('#amount')
->assertSeeIn('@button-cancel', 'Cancel')
- ->assertSeeIn('@body #payment-form button', 'Continue')
+ ->assertSeeIn('@button-action', 'Continue')
// Test error handling
->type('@body #amount', 'aaa')
- ->click('@body #payment-form button')
+ ->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.')
// Submit valid data
->type('@body #amount', '12.34')
// Note we use double click to assert it does not create redundant requests
- ->click('@body #payment-form button')
- ->click('@body #payment-form button');
+ ->click('@button-action')
+ ->click('@button-action');
})
->on(new PaymentMollie())
->assertSeeIn('@title', \config('app.name') . ' Payment')
->assertSeeIn('@amount', 'CHF 12.34');
$this->assertSame(1, $user->wallets()->first()->payments()->count());
// Looks like the Mollie testing mode is limited.
// We'll select credit card method and mark the payment as paid
// We can't do much more, we have to trust Mollie their page works ;)
// For some reason I don't get the method selection form, it
// immediately jumps to the next step. Let's detect that
if ($browser->element('@methods')) {
$browser->click('@methods button.grid-button-creditcard')
->waitFor('button.form__button');
}
$browser->click('@status-table input[value="paid"]')
->click('button.form__button');
// Now it should redirect back to wallet page and in background
// use the webhook to update payment status (and balance).
// Looks like in test-mode the webhook is executed before redirect
// so we can expect balance updated on the wallet page
$browser->waitForLocation('/wallet')
->on(new WalletPage())
->assertSeeIn('@main .card-title', 'Account balance 12,34 CHF');
});
}
/**
* Test the auto-payment setup process
*
* @group mollie
*/
public function testAutoPaymentSetup(): void
{
$user = $this->getTestUser('payment-test@kolabnow.com', [
'password' => 'simple123',
]);
$this->browse(function (Browser $browser) use ($user) {
$browser->visit(new Home())
->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie'])
->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
- ->click('@main button')
+ ->assertMissing('@body #mandate-form .alert')
+ ->click('@main #mandate-form button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'Top up your wallet')
- ->assertSeeIn('@button-cancel', 'Cancel')
- ->assertSeeIn('@body #mandate-form button', 'Set up auto-payment')
- ->assertSeeIn('@body #mandate-form p', 'Add auto-payment, so you never')
- ->assertMissing('@body #mandate-form .alert')
- ->click('@body #mandate-form button')
- ->assertSeeIn('@title', 'Add auto-payment')
+ $browser->assertSeeIn('@title', 'Add auto-payment')
+ ->waitFor('#payment-method-selection #creditcard')
+ ->assertMissing('#payment-method-selection #paypal')
+ ->assertMissing('#payment-method-selection #banktransfer')
+ ->click('#creditcard');
+ })
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Add auto-payment')
->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by')
->assertValue('@body #mandate_amount', PaymentProvider::MIN_AMOUNT / 100)
->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore
->assertValue('@body #mandate_balance', '0')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Continue')
// Test error handling
->type('@body #mandate_amount', 'aaa')
->type('@body #mandate_balance', '-1')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertVisible('@body #mandate_balance.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
->assertSeeIn('#mandate_balance + span + .invalid-feedback', 'The balance must be at least 0.')
->type('@body #mandate_amount', 'aaa')
->type('@body #mandate_balance', '0')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertMissing('@body #mandate_balance.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
->assertMissing('#mandate_balance + span + .invalid-feedback')
// Submit valid data
->type('@body #mandate_amount', '100')
->type('@body #mandate_balance', '0')
// Note we use double click to assert it does not create redundant requests
->click('@button-action')
->click('@button-action');
})
->on(new PaymentMollie())
->assertSeeIn('@title', \config('app.name') . ' Auto-Payment Setup')
->assertMissing('@amount')
->submitValidCreditCard()
->waitForLocation('/wallet')
->visit('/wallet?paymentProvider=mollie')
- ->on(new WalletPage())
- ->click('@main button')
- ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $expected = 'Auto-payment is set to fill up your account by 100 CHF every'
- . ' time your account balance gets under 0 CHF. You will be charged'
- . ' via Mastercard (**** **** **** 6787).';
-
- $browser->assertSeeIn('@title', 'Top up your wallet')
- ->waitFor('#mandate-info')
- ->assertSeeIn('#mandate-info p:first-child', $expected)
- ->assertMissing('@body .alert')
- ->click('@button-cancel');
- });
+ ->waitFor('#mandate-info')
+ ->assertPresent('#mandate-info p:first-child')
+ ->assertSeeIn(
+ '#mandate-info p:first-child',
+ 'Auto-payment is set to fill up your account by 100 CHF ' .
+ 'every time your account balance gets under 0 CHF.'
+ )
+ ->assertSeeIn(
+ '#mandate-info p:nth-child(2)',
+ 'Mastercard (**** **** **** 6787)'
+ )
+ ->assertMissing('@body .alert');
$this->assertSame(1, $user->wallets()->first()->payments()->count());
});
// Test updating (disabled) auto-payment
$this->browse(function (Browser $browser) use ($user) {
$wallet = $user->wallets()->first();
$wallet->setSetting('mandate_disabled', 1);
$browser->refresh()
->on(new WalletPage())
- ->click('@main button')
+ ->waitFor('#mandate-info')
+ ->assertSeeIn(
+ '#mandate-info .disabled-mandate',
+ 'The configured auto-payment has been disabled'
+ )
+ ->assertSeeIn('#mandate-info button.btn-primary', 'Change auto-payment')
+ ->click('#mandate-info button.btn-primary')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->waitFor('@body #mandate-info')
- ->assertSeeIn(
- '@body #mandate-info .disabled-mandate',
- 'The configured auto-payment has been disabled'
- )
- ->assertSeeIn('@body #mandate-info button.btn-primary', 'Change auto-payment')
- ->click('@body #mandate-info button.btn-primary')
- ->assertSeeIn('@title', 'Update auto-payment')
- ->assertSeeIn(
- '@body form .disabled-mandate',
- 'The auto-payment is disabled.'
- )
- ->assertValue('@body #mandate_amount', '100')
- ->assertValue('@body #mandate_balance', '0')
- ->assertSeeIn('@button-cancel', 'Cancel')
- ->assertSeeIn('@button-action', 'Submit')
- // Test error handling
- ->type('@body #mandate_amount', 'aaa')
- ->click('@button-action')
- ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
- ->assertVisible('@body #mandate_amount.is-invalid')
- ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
- // Submit valid data
- ->type('@body #mandate_amount', '50')
- ->click('@button-action');
+ $browser->assertSeeIn('@title', 'Update auto-payment')
+ ->assertSeeIn(
+ '@body form .disabled-mandate',
+ 'The auto-payment is disabled.'
+ )
+ ->assertValue('@body #mandate_amount', '100')
+ ->assertValue('@body #mandate_balance', '0')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Submit')
+ // Test error handling
+ ->type('@body #mandate_amount', 'aaa')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->assertVisible('@body #mandate_amount.is-invalid')
+ ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
+ // Submit valid data
+ ->type('@body #mandate_amount', '50')
+ ->click('@button-action');
})
->waitUntilMissing('#payment-dialog')
->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.')
- // Open the dialog again and make sure the "disabled" text isn't there
- ->click('@main button')
- ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertMissing('@body #mandate-info .disabled-mandate')
- ->click('@body #mandate-info button.btn-primary')
- ->assertMissing('@body form .disabled-mandate')
- ->click('@button-cancel');
- });
+ // make sure the "disabled" text isn't there
+ ->assertMissing('#mandate-info .disabled-mandate')
+ ->click('#mandate-info button.btn-primary')
+ ->assertMissing('form .disabled-mandate')
+ ->click('button.modal-cancel');
});
// Test deleting auto-payment
$this->browse(function (Browser $browser) {
$browser->on(new WalletPage())
- ->click('@main button')
- ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@body #mandate-info button.btn-danger', 'Cancel auto-payment')
- ->click('@body #mandate-info button.btn-danger')
- ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
- ->assertVisible('@body #mandate-form')
- ->assertMissing('@body #mandate-info')
- ->click('@button-cancel');
- });
+ ->waitFor('#mandate-info')
+ ->assertSeeIn('#mandate-info * button.btn-danger', 'Cancel auto-payment')
+ ->assertVisible('#mandate-info * button.btn-danger')
+ ->click('#mandate-info * button.btn-danger')
+ ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
+ ->assertVisible('#mandate-form')
+ ->assertMissing('#mandate-info');
});
// Test pending and failed mandate
$this->browse(function (Browser $browser) {
$browser->on(new WalletPage())
- ->click('@main button')
+ ->assertMissing('@body #mandate-form .alert')
+ ->click('@main #mandate-form button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'Top up your wallet')
+ $browser->assertSeeIn('@title', 'Add auto-payment')
+ ->waitFor('#payment-method-selection #creditcard')
+ ->click('#creditcard');
+ })
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Add auto-payment')
->assertSeeIn('@button-cancel', 'Cancel')
- ->assertSeeIn('@body #mandate-form button', 'Set up auto-payment')
- ->assertSeeIn('@body #mandate-form p', 'Add auto-payment, so you never')
- ->assertMissing('@body #mandate-form .alert')
- ->click('@body #mandate-form button')
- ->assertSeeIn('@title', 'Add auto-payment')
- ->assertMissing('@body .alert')
+ ->assertSeeIn('@button-action', 'Continue')
// Submit valid data
->type('@body #mandate_amount', '100')
->type('@body #mandate_balance', '0')
->click('@button-action');
})
->on(new PaymentMollie())
->submitValidCreditCard('open')
->waitForLocation('/wallet')
->visit('/wallet?paymentProvider=mollie')
->on(new WalletPage())
- ->click('@main button')
- ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $expected = 'Auto-payment is set to fill up your account by 100 CHF every'
- . ' time your account balance gets under 0 CHF. You will be charged'
- . ' via Credit Card.';
+ ->assertSeeIn(
+ '#mandate-info .alert-warning',
+ 'The setup of the automatic payment is still in progress.'
+ )
+ // Delete the mandate
+ ->click('#mandate-info * button.btn-danger')
+ ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
+ ->assertMissing('@body #mandate-form .alert')
- $browser->assertSeeIn('@title', 'Top up your wallet')
- ->waitFor('#mandate-info')
- ->assertSeeIn('#mandate-info p:first-child', $expected)
- ->assertSeeIn(
- '#mandate-info .alert-warning',
- 'The setup of the automatic payment is still in progress.'
- )
- ->assertSeeIn('@body #mandate-info .btn-danger', 'Cancel auto-payment')
- ->assertSeeIn('@body #mandate-info .btn-primary', 'Change auto-payment')
- // Delete the mandate
- ->click('@body #mandate-info .btn-danger')
- ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
- ->assertSeeIn('@body #mandate-form p', 'Add auto-payment, so you never')
- ->assertMissing('@body #mandate-form .alert')
- ->click('@body #mandate-form button')
- ->assertSeeIn('@title', 'Add auto-payment')
- ->assertMissing('@body .alert')
+ // Create a new mandate
+ ->click('@main #mandate-form button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Add auto-payment')
+ ->waitFor('#payment-method-selection #creditcard')
+ ->click('#creditcard');
+ })
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Add auto-payment')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Continue')
// Submit valid data
->type('@body #mandate_amount', '100')
->type('@body #mandate_balance', '0')
->click('@button-action');
})
->on(new PaymentMollie())
->submitValidCreditCard('failed')
->waitForLocation('/wallet')
->visit('/wallet?paymentProvider=mollie')
->on(new WalletPage())
+ ->waitFor('#mandate-form .alert-danger')
+ ->assertSeeIn(
+ '#mandate-form .alert-danger',
+ 'The setup of automatic payments failed. Restart the process to enable'
+ )
->click('@main button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->waitFor('#mandate-form')
- ->assertMissing('#mandate-info')
- ->assertSeeIn('#mandate-form p', 'Add auto-payment')
- ->waitFor('#mandate-form .alert-danger')
- ->assertSeeIn(
- '#mandate-form .alert-danger',
- 'The setup of automatic payments failed. Restart the process to enable'
- )
- ->assertSeeIn('@body #mandate-form .btn-primary', 'Set up auto-payment');
+ ->assertMissing('#mandate-info');
});
});
}
}
diff --git a/src/tests/Browser/PaymentStripeTest.php b/src/tests/Browser/PaymentStripeTest.php
index c37e47aa..5bb34510 100644
--- a/src/tests/Browser/PaymentStripeTest.php
+++ b/src/tests/Browser/PaymentStripeTest.php
@@ -1,202 +1,234 @@
<?php
namespace Tests\Browser;
use App\Providers\PaymentProvider;
use App\Wallet;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\PaymentStripe;
use Tests\Browser\Pages\Wallet as WalletPage;
use Tests\TestCaseDusk;
class PaymentStripeTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('payment-test@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('payment-test@kolabnow.com');
parent::tearDown();
}
/**
* Test the payment process
*
* @group stripe
*/
public function testPayment(): void
{
$user = $this->getTestUser('payment-test@kolabnow.com', [
'password' => 'simple123',
]);
$this->browse(function (Browser $browser) use ($user) {
$browser->visit(new Home())
->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'stripe'])
->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
->assertSeeIn('@main button', 'Add credit')
->click('@main button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Top up your wallet')
+ ->waitFor('#payment-method-selection #creditcard')
+ ->waitFor('#payment-method-selection #paypal')
+ ->assertMissing('#payment-method-selection #banktransfer')
+ ->click('#creditcard');
+ })
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Top up your wallet')
->assertFocused('#amount')
->assertSeeIn('@button-cancel', 'Cancel')
- ->assertSeeIn('@body #payment-form button', 'Continue')
+ ->assertSeeIn('@button-action', 'Continue')
// Test error handling
->type('@body #amount', 'aaa')
- ->click('@body #payment-form button')
+ ->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.')
// Submit valid data
->type('@body #amount', '12.34')
- ->click('@body #payment-form button');
+ // Note we use double click to assert it does not create redundant requests
+ ->click('@button-action')
+ ->click('@button-action');
})
->on(new PaymentStripe())
->assertSeeIn('@title', \config('app.name') . ' Payment')
->assertSeeIn('@amount', 'CHF 12.34')
->assertValue('@email-input', $user->email)
->submitValidCreditCard();
// Now it should redirect back to wallet page and in background
// use the webhook to update payment status (and balance).
// Looks like in test-mode the webhook is executed before redirect
// so we can expect balance updated on the wallet page
$browser->waitForLocation('/wallet', 30) // need more time than default 5 sec.
->on(new WalletPage())
->assertSeeIn('@main .card-title', 'Account balance 12,34 CHF');
});
}
/**
* Test the auto-payment setup process
*
* @group stripe
*/
public function testAutoPaymentSetup(): void
{
$user = $this->getTestUser('payment-test@kolabnow.com', [
'password' => 'simple123',
]);
// Test creating auto-payment
$this->browse(function (Browser $browser) use ($user) {
$browser->visit(new Home())
->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'stripe'])
->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
- ->click('@main button')
+ ->assertMissing('@body #mandate-form .alert')
+ ->click('@main #mandate-form button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'Top up your wallet')
- ->assertSeeIn('@button-cancel', 'Cancel')
- ->assertSeeIn('@body #mandate-form button', 'Set up auto-payment')
- ->click('@body #mandate-form button')
- ->assertSeeIn('@title', 'Add auto-payment')
+ $browser->assertSeeIn('@title', 'Add auto-payment')
+ ->waitFor('#payment-method-selection #creditcard')
+ ->assertMissing('#payment-method-selection #paypal')
+ ->assertMissing('#payment-method-selection #banktransfer')
+ ->click('#creditcard');
+ })
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Add auto-payment')
->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by')
->assertValue('@body #mandate_amount', PaymentProvider::MIN_AMOUNT / 100)
->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore
->assertValue('@body #mandate_balance', '0')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Continue')
// Test error handling
->type('@body #mandate_amount', 'aaa')
->type('@body #mandate_balance', '-1')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertVisible('@body #mandate_balance.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
->assertSeeIn('#mandate_balance + span + .invalid-feedback', 'The balance must be at least 0.')
->type('@body #mandate_amount', 'aaa')
->type('@body #mandate_balance', '0')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertMissing('@body #mandate_balance.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
->assertMissing('#mandate_balance + span + .invalid-feedback')
// Submit valid data
->type('@body #mandate_amount', '100')
->type('@body #mandate_balance', '0')
+ // Note we use double click to assert it does not create redundant requests
+ ->click('@button-action')
->click('@button-action');
})
->on(new PaymentStripe())
->assertMissing('@title')
->assertMissing('@amount')
->assertValue('@email-input', $user->email)
->submitValidCreditCard()
->waitForLocation('/wallet', 30) // need more time than default 5 sec.
->visit('/wallet?paymentProvider=stripe')
- ->on(new WalletPage())
- ->click('@main button')
- ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $expected = 'Auto-payment is set to fill up your account by 100 CHF every'
- . ' time your account balance gets under 0 CHF. You will be charged'
- . ' via Visa (**** **** **** 4242).';
-
- $browser->assertSeeIn('@title', 'Top up your wallet')
- ->waitFor('#mandate-info')
- ->assertSeeIn('#mandate-info p:first-child', $expected)
- ->click('@button-cancel');
- });
+ ->waitFor('#mandate-info')
+ ->assertPresent('#mandate-info p:first-child')
+ ->assertSeeIn(
+ '#mandate-info p:first-child',
+ 'Auto-payment is set to fill up your account by 100 CHF ' .
+ 'every time your account balance gets under 0 CHF.'
+ )
+ ->assertSeeIn(
+ '#mandate-info p:nth-child(2)',
+ 'Visa (**** **** **** 4242)'
+ )
+ ->assertMissing('@body .alert');
});
- // Test updating auto-payment
- $this->browse(function (Browser $browser) {
- $browser->on(new WalletPage())
- ->click('@main button')
+
+ // Test updating (disabled) auto-payment
+ $this->browse(function (Browser $browser) use ($user) {
+ $wallet = $user->wallets()->first();
+ $wallet->setSetting('mandate_disabled', 1);
+
+ $browser->refresh()
+ ->on(new WalletPage())
+ ->waitFor('#mandate-info')
+ ->assertSeeIn(
+ '#mandate-info .disabled-mandate',
+ 'The configured auto-payment has been disabled'
+ )
+ ->assertSeeIn('#mandate-info button.btn-primary', 'Change auto-payment')
+ ->click('#mandate-info button.btn-primary')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@body #mandate-info button.btn-primary', 'Change auto-payment')
- ->click('@body #mandate-info button.btn-primary')
- ->assertSeeIn('@title', 'Update auto-payment')
- ->assertValue('@body #mandate_amount', '100')
- ->assertValue('@body #mandate_balance', '0')
- ->assertSeeIn('@button-cancel', 'Cancel')
- ->assertSeeIn('@button-action', 'Submit')
- // Test error handling
- ->type('@body #mandate_amount', 'aaa')
- ->click('@button-action')
- ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
- ->assertVisible('@body #mandate_amount.is-invalid')
- ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
- // Submit valid data
- ->type('@body #mandate_amount', '50')
- ->click('@button-action');
+ $browser->assertSeeIn('@title', 'Update auto-payment')
+ ->assertSeeIn(
+ '@body form .disabled-mandate',
+ 'The auto-payment is disabled.'
+ )
+ ->assertValue('@body #mandate_amount', '100')
+ ->assertValue('@body #mandate_balance', '0')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Submit')
+ // Test error handling
+ ->type('@body #mandate_amount', 'aaa')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->assertVisible('@body #mandate_amount.is-invalid')
+ ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
+ // Submit valid data
+ ->type('@body #mandate_amount', '50')
+ ->click('@button-action');
})
->waitUntilMissing('#payment-dialog')
- ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.');
+ ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.')
+ // make sure the "disabled" text isn't there
+ ->assertMissing('#mandate-info .disabled-mandate')
+ ->click('#mandate-info button.btn-primary')
+ ->assertMissing('form .disabled-mandate')
+ ->click('button.modal-cancel');
});
// Test deleting auto-payment
$this->browse(function (Browser $browser) {
$browser->on(new WalletPage())
- ->click('@main button')
- ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@body #mandate-info button.btn-danger', 'Cancel auto-payment')
- ->click('@body #mandate-info button.btn-danger')
- ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
- ->assertVisible('@body #mandate-form')
- ->assertMissing('@body #mandate-info');
- });
+ ->waitFor('#mandate-info')
+ ->assertSeeIn('#mandate-info * button.btn-danger', 'Cancel auto-payment')
+ ->assertVisible('#mandate-info * button.btn-danger')
+ ->click('#mandate-info * button.btn-danger')
+ ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
+ ->assertVisible('#mandate-form')
+ ->assertMissing('#mandate-info');
});
}
}
diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php
index ef77477a..bf053f35 100644
--- a/src/tests/Feature/Controller/PaymentsMollieTest.php
+++ b/src/tests/Feature/Controller/PaymentsMollieTest.php
@@ -1,815 +1,920 @@
<?php
namespace Tests\Feature\Controller;
use App\Http\Controllers\API\V4\PaymentsController;
use App\Payment;
use App\Providers\PaymentProvider;
use App\Transaction;
use App\Wallet;
use App\WalletSetting;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
use Tests\BrowserAddonTrait;
use Tests\MollieMocksTrait;
class PaymentsMollieTest extends TestCase
{
use MollieMocksTrait;
use BrowserAddonTrait;
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
// All tests in this file use Mollie
\config(['services.payment_provider' => 'mollie']);
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
$types = [
Transaction::WALLET_CREDIT,
Transaction::WALLET_REFUND,
Transaction::WALLET_CHARGEBACK,
];
Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
$types = [
Transaction::WALLET_CREDIT,
Transaction::WALLET_REFUND,
Transaction::WALLET_CHARGEBACK,
];
Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete();
parent::tearDown();
}
/**
* Test creating/updating/deleting an outo-payment mandate
*
* @group mollie
*/
public function testMandates(): void
{
// Unauth access not allowed
$response = $this->get("api/v4/payments/mandate");
$response->assertStatus(401);
$response = $this->post("api/v4/payments/mandate", []);
$response->assertStatus(401);
$response = $this->put("api/v4/payments/mandate", []);
$response->assertStatus(401);
$response = $this->delete("api/v4/payments/mandate");
$response->assertStatus(401);
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Test creating a mandate (invalid input)
$post = [];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertSame('The amount field is required.', $json['errors']['amount'][0]);
$this->assertSame('The balance field is required.', $json['errors']['balance'][0]);
// Test creating a mandate (invalid input)
$post = ['amount' => 100, 'balance' => 'a'];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame('The balance must be a number.', $json['errors']['balance'][0]);
// Test creating a mandate (amount smaller than the minimum value)
$post = ['amount' => -100, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF';
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Test creating a mandate (negative balance, amount too small)
Wallet::where('id', $wallet->id)->update(['balance' => -2000]);
$post = ['amount' => PaymentProvider::MIN_AMOUNT / 100, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame("The specified amount does not cover the balance on the account.", $json['errors']['amount']);
// Test creating a mandate (valid input)
$post = ['amount' => 20.10, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']);
// Assert the proper payment amount has been used
$payment = Payment::where('id', $json['id'])->first();
$this->assertSame(2010, $payment->amount);
$this->assertSame($wallet->id, $payment->wallet_id);
- $this->assertSame("Kolab Now Auto-Payment Setup", $payment->description);
+ $this->assertSame(\config('app.name') . " Auto-Payment Setup", $payment->description);
$this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type);
// Test fetching the mandate information
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertEquals('Credit Card', $json['method']);
$this->assertSame(true, $json['isPending']);
$this->assertSame(false, $json['isValid']);
$this->assertSame(false, $json['isDisabled']);
$mandate_id = $json['id'];
// We would have to invoke a browser to accept the "first payment" to make
// the mandate validated/completed. Instead, we'll mock the mandate object.
$mollie_response = [
'resource' => 'mandate',
'id' => $mandate_id,
'status' => 'valid',
'method' => 'creditcard',
'details' => [
'cardNumber' => '4242',
'cardLabel' => 'Visa',
],
'customerId' => 'cst_GMfxGPt7Gj',
'createdAt' => '2020-04-28T11:09:47+00:00',
];
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$wallet = $user->wallets()->first();
$wallet->setSetting('mandate_disabled', 1);
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertEquals('Visa (**** **** **** 4242)', $json['method']);
$this->assertSame(false, $json['isPending']);
$this->assertSame(true, $json['isValid']);
$this->assertSame(true, $json['isDisabled']);
Bus::fake();
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = 1000;
$wallet->save();
// Test updating mandate details (invalid input)
$post = [];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertSame('The amount field is required.', $json['errors']['amount'][0]);
$this->assertSame('The balance field is required.', $json['errors']['balance'][0]);
$post = ['amount' => -100, 'balance' => 0];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Test updating a mandate (valid input)
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['amount' => 30.10, 'balance' => 10];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been updated.', $json['message']);
$this->assertSame($mandate_id, $json['id']);
$this->assertFalse($json['isDisabled']);
$wallet->refresh();
$this->assertEquals(30.10, $wallet->getSetting('mandate_amount'));
$this->assertEquals(10, $wallet->getSetting('mandate_balance'));
Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 0);
// Test updating a disabled mandate (invalid input)
$wallet->setSetting('mandate_disabled', 1);
$wallet->balance = -2000;
$wallet->save();
$user->refresh(); // required so the controller sees the wallet update from above
$post = ['amount' => 15.10, 'balance' => 1];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']);
// Test updating a disabled mandate (valid input)
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['amount' => 30, 'balance' => 1];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been updated.', $json['message']);
$this->assertSame($mandate_id, $json['id']);
$this->assertFalse($json['isDisabled']);
Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1);
Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) {
$job_wallet = $this->getObjectProperty($job, 'wallet');
return $job_wallet->id === $wallet->id;
});
$this->unmockMollie();
// Delete mandate
$response = $this->actingAs($user)->delete("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been removed.', $json['message']);
// Confirm with Mollie the mandate does not exist
$customer_id = $wallet->getSetting('mollie_id');
$this->expectException(\Mollie\Api\Exceptions\ApiException::class);
$this->expectExceptionMessageMatches('/410: Gone/');
$mandate = mollie()->mandates()->getForId($customer_id, $mandate_id);
$this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id'));
// Test Mollie's "410 Gone" response handling when fetching the mandate info
// It is expected to remove the mandate reference
$mollie_response = [
'status' => 410,
'title' => "Gone",
'detail' => "You are trying to access an object, which has previously been deleted",
'_links' => [
'documentation' => [
'href' => "https://docs.mollie.com/errors",
'type' => "text/html"
]
]
];
$responseStack = $this->mockMollie();
$responseStack->append(new Response(410, [], json_encode($mollie_response)));
$wallet->fresh()->setSetting('mollie_mandate_id', '123');
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse(array_key_exists('id', $json));
$this->assertFalse(array_key_exists('method', $json));
$this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id'));
}
/**
* Test creating a payment and receiving a status via webhook
*
* @group mollie
*/
public function testStoreAndWebhook(): void
{
Bus::fake();
// Unauth access not allowed
$response = $this->post("api/v4/payments", []);
$response->assertStatus(401);
+ // Invalid amount
$user = $this->getTestUser('john@kolab.org');
$post = ['amount' => -1];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF';
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
- $post = ['amount' => '12.34'];
+ // Invalid currency
+ $post = ['amount' => '12.34', 'currency' => 'FOO', 'methodId' => 'creditcard'];
+ $response = $this->actingAs($user)->post("api/v4/payments", $post);
+ $response->assertStatus(500);
+
+ // Successful payment
+ $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']);
$wallet = $user->wallets()->first();
$payments = Payment::where('wallet_id', $wallet->id)->get();
$this->assertCount(1, $payments);
$payment = $payments[0];
$this->assertSame(1234, $payment->amount);
$this->assertSame(\config('app.name') . ' Payment', $payment->description);
$this->assertSame('open', $payment->status);
$this->assertEquals(0, $wallet->balance);
// Test the webhook
// Note: Webhook end-point does not require authentication
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
];
// We'll trigger the webhook with payment id and use mocking for
// a request to the Mollie payments API. We cannot force Mollie
// to make the payment status change.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(1234, $transaction->amount);
$this->assertSame(
"Payment transaction {$payment->id} using Mollie",
$transaction->description
);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
// Verify "paid -> open -> paid" scenario, assert that balance didn't change
$mollie_response['status'] = 'open';
unset($mollie_response['paidAt']);
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
$mollie_response['status'] = 'paid';
$mollie_response['paidAt'] = date('c');
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Test for payment failure
Bus::fake();
$payment->refresh();
$payment->status = PaymentProvider::STATUS_OPEN;
$payment->save();
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "failed",
"mode" => "test",
];
// We'll trigger the webhook with payment id and use mocking for
// a request to the Mollie payments API. We cannot force Mollie
// to make the payment status change.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame('failed', $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
}
/**
* Test automatic payment charges
*
* @group mollie
*/
public function testTopUp(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Create a valid mandate first (balance=0, so there's no extra payment yet)
$this->createMandate($wallet, ['amount' => 20.10, 'balance' => 0]);
$wallet->setSetting('mandate_balance', 10);
// Expect a recurring payment as we have a valid mandate at this point
// and the balance is below the threshold
$result = PaymentsController::topUpWallet($wallet);
$this->assertTrue($result);
// Check that the payments table contains a new record with proper amount.
// There should be two records, one for the mandate payment and another for
// the top-up payment
$payments = $wallet->payments()->orderBy('amount')->get();
$this->assertCount(2, $payments);
$this->assertSame(0, $payments[0]->amount);
$this->assertSame(2010, $payments[1]->amount);
$payment = $payments[1];
// In mollie we don't have to wait for a webhook, the response to
// PaymentIntent already sets the status to 'paid', so we can test
// immediately the balance update
// Assert that email notification job has been dispatched
$this->assertSame(PaymentProvider::STATUS_PAID, $payment->status);
$this->assertEquals(2010, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(2010, $transaction->amount);
$this->assertSame(
"Auto-payment transaction {$payment->id} using Mastercard (**** **** **** 6787)",
$transaction->description
);
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
// Expect no payment if the mandate is disabled
$wallet->setSetting('mandate_disabled', 1);
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
// Expect no payment if balance is ok
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = 1000;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
// Expect no payment if the top-up amount is not enough
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = -2050;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) {
$job_wallet = $this->getObjectProperty($job, 'wallet');
return $job_wallet->id === $wallet->id;
});
// Expect no payment if there's no mandate
$wallet->setSetting('mollie_mandate_id', null);
$wallet->balance = 0;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
// Test webhook for recurring payments
$wallet->transactions()->delete();
$responseStack = $this->mockMollie();
Bus::fake();
$payment->refresh();
$payment->status = PaymentProvider::STATUS_OPEN;
$payment->save();
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
];
// We'll trigger the webhook with payment id and use mocking for
// a request to the Mollie payments API. We cannot force Mollie
// to make the payment status change.
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(2010, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(2010, $transaction->amount);
$this->assertSame(
"Auto-payment transaction {$payment->id} using Mollie",
$transaction->description
);
// Assert that email notification job has been dispatched
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
Bus::fake();
// Test for payment failure
$payment->refresh();
$payment->status = PaymentProvider::STATUS_OPEN;
$payment->save();
$wallet->setSetting('mollie_mandate_id', 'xxx');
$wallet->setSetting('mandate_disabled', null);
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "failed",
"mode" => "test",
];
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status);
$this->assertEquals(2010, $wallet->balance);
$this->assertTrue(!empty($wallet->getSetting('mandate_disabled')));
// Assert that email notification job has been dispatched
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
$this->unmockMollie();
}
/**
* Test refund/chargeback handling by the webhook
*
* @group mollie
*/
public function testRefundAndChargeback(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->transactions()->delete();
$mollie = PaymentProvider::factory('mollie');
// Create a paid payment
$payment = Payment::create([
'id' => 'tr_123456',
'status' => PaymentProvider::STATUS_PAID,
'amount' => 123,
+ 'currency_amount' => 123,
+ 'currency' => 'CHF',
'type' => PaymentProvider::TYPE_ONEOFF,
'wallet_id' => $wallet->id,
'provider' => 'mollie',
'description' => 'test',
]);
// Test handling a refund by the webhook
$mollie_response1 = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
"_links" => [
"refunds" => [
"href" => "https://api.mollie.com/v2/payments/{$payment->id}/refunds",
"type" => "application/hal+json"
]
]
];
$mollie_response2 = [
"count" => 1,
"_links" => [],
"_embedded" => [
"refunds" => [
[
"resource" => "refund",
"id" => "re_123456",
"status" => \Mollie\Api\Types\RefundStatus::STATUS_REFUNDED,
"paymentId" => $payment->id,
"description" => "refund desc",
"amount" => [
"currency" => "CHF",
"value" => "1.01",
],
]
]
]
];
// We'll trigger the webhook with payment id and use mocking for
// requests to the Mollie payments API.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response1)));
$responseStack->append(new Response(200, [], json_encode($mollie_response2)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertEquals(-101, $wallet->balance);
$transactions = $wallet->transactions()->where('type', Transaction::WALLET_REFUND)->get();
$this->assertCount(1, $transactions);
$this->assertSame(-101, $transactions[0]->amount);
$this->assertSame(Transaction::WALLET_REFUND, $transactions[0]->type);
$this->assertSame("refund desc", $transactions[0]->description);
$payments = $wallet->payments()->where('id', 're_123456')->get();
$this->assertCount(1, $payments);
$this->assertSame(-101, $payments[0]->amount);
$this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status);
$this->assertSame(PaymentProvider::TYPE_REFUND, $payments[0]->type);
$this->assertSame("mollie", $payments[0]->provider);
$this->assertSame("refund desc", $payments[0]->description);
// Test handling a chargeback by the webhook
$mollie_response1["_links"] = [
"chargebacks" => [
"href" => "https://api.mollie.com/v2/payments/{$payment->id}/chargebacks",
"type" => "application/hal+json"
]
];
$mollie_response2 = [
"count" => 1,
"_links" => [],
"_embedded" => [
"chargebacks" => [
[
"resource" => "chargeback",
"id" => "chb_123456",
"paymentId" => $payment->id,
"amount" => [
"currency" => "CHF",
"value" => "0.15",
],
]
]
]
];
// We'll trigger the webhook with payment id and use mocking for
// requests to the Mollie payments API.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response1)));
$responseStack->append(new Response(200, [], json_encode($mollie_response2)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertEquals(-116, $wallet->balance);
$transactions = $wallet->transactions()->where('type', Transaction::WALLET_CHARGEBACK)->get();
$this->assertCount(1, $transactions);
$this->assertSame(-15, $transactions[0]->amount);
$this->assertSame(Transaction::WALLET_CHARGEBACK, $transactions[0]->type);
$this->assertSame('', $transactions[0]->description);
$payments = $wallet->payments()->where('id', 'chb_123456')->get();
$this->assertCount(1, $payments);
$this->assertSame(-15, $payments[0]->amount);
$this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status);
$this->assertSame(PaymentProvider::TYPE_CHARGEBACK, $payments[0]->type);
$this->assertSame("mollie", $payments[0]->provider);
$this->assertSame('', $payments[0]->description);
Bus::assertNotDispatched(\App\Jobs\PaymentEmail::class);
$this->unmockMollie();
}
/**
* Create Mollie's auto-payment mandate using our API and Chrome browser
*/
protected function createMandate(Wallet $wallet, array $params)
{
// Use the API to create a first payment with a mandate
$response = $this->actingAs($wallet->owner)->post("api/v4/payments/mandate", $params);
$response->assertStatus(200);
$json = $response->json();
// There's no easy way to confirm a created mandate.
// The only way seems to be to fire up Chrome on checkout page
// and do actions with use of Dusk browser.
$this->startBrowser()
->visit($json['redirectUrl'])
->click('input[value="paid"]')
->click('button.form__button');
$this->stopBrowser();
}
+
+
+ /**
+ * Test listing a pending payment
+ *
+ * @group mollie
+ */
+ public function testListingPayments(): void
+ {
+ Bus::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+
+ //Empty response
+ $response = $this->actingAs($user)->get("api/v4/payments/pending");
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame(0, $json['count']);
+ $this->assertSame(1, $json['page']);
+ $this->assertSame(false, $json['hasMore']);
+ $this->assertCount(0, $json['list']);
+
+ $response = $this->actingAs($user)->get("api/v4/payments/has-pending");
+ $json = $response->json();
+ $this->assertSame(false, $json['hasPending']);
+
+ $wallet = $user->wallets()->first();
+
+ // Successful payment
+ $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard'];
+ $response = $this->actingAs($user)->post("api/v4/payments", $post);
+ $response->assertStatus(200);
+
+ //A response
+ $response = $this->actingAs($user)->get("api/v4/payments/pending");
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame(1, $json['count']);
+ $this->assertSame(1, $json['page']);
+ $this->assertSame(false, $json['hasMore']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame(PaymentProvider::STATUS_OPEN, $json['list'][0]['status']);
+
+ $response = $this->actingAs($user)->get("api/v4/payments/has-pending");
+ $json = $response->json();
+ $this->assertSame(true, $json['hasPending']);
+
+ // Set the payment to paid
+ $payments = Payment::where('wallet_id', $wallet->id)->get();
+
+ $this->assertCount(1, $payments);
+ $payment = $payments[0];
+
+ $payment->status = PaymentProvider::STATUS_PAID;
+ $payment->save();
+
+ // They payment should be gone from the pending list now
+ $response = $this->actingAs($user)->get("api/v4/payments/pending");
+ $json = $response->json();
+ $this->assertSame('success', $json['status']);
+ $this->assertSame(0, $json['count']);
+ $this->assertCount(0, $json['list']);
+
+ $response = $this->actingAs($user)->get("api/v4/payments/has-pending");
+ $json = $response->json();
+ $this->assertSame(false, $json['hasPending']);
+ }
+
+ /**
+ * Test listing payment methods
+ *
+ * @group mollie
+ */
+ public function testListingPaymentMethods(): void
+ {
+ Bus::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+
+ $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF);
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('creditcard', $json[0]['id']);
+ $this->assertSame('paypal', $json[1]['id']);
+
+ $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_RECURRING);
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $this->assertCount(1, $json);
+ $this->assertSame('creditcard', $json[0]['id']);
+ }
}
diff --git a/src/tests/Feature/Controller/PaymentsStripeTest.php b/src/tests/Feature/Controller/PaymentsStripeTest.php
index d1a56de1..e9f70c5d 100644
--- a/src/tests/Feature/Controller/PaymentsStripeTest.php
+++ b/src/tests/Feature/Controller/PaymentsStripeTest.php
@@ -1,709 +1,743 @@
<?php
namespace Tests\Feature\Controller;
use App\Http\Controllers\API\V4\PaymentsController;
use App\Payment;
use App\Providers\PaymentProvider;
use App\Transaction;
use App\Wallet;
use App\WalletSetting;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
use Tests\StripeMocksTrait;
class PaymentsStripeTest extends TestCase
{
use StripeMocksTrait;
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
// All tests in this file use Stripe
\config(['services.payment_provider' => 'stripe']);
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
Transaction::where('object_id', $wallet->id)
->where('type', Transaction::WALLET_CREDIT)->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
Transaction::where('object_id', $wallet->id)
->where('type', Transaction::WALLET_CREDIT)->delete();
parent::tearDown();
}
/**
* Test creating/updating/deleting an outo-payment mandate
*
* @group stripe
*/
public function testMandates(): void
{
Bus::fake();
// Unauth access not allowed
$response = $this->get("api/v4/payments/mandate");
$response->assertStatus(401);
$response = $this->post("api/v4/payments/mandate", []);
$response->assertStatus(401);
$response = $this->put("api/v4/payments/mandate", []);
$response->assertStatus(401);
$response = $this->delete("api/v4/payments/mandate");
$response->assertStatus(401);
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Test creating a mandate (invalid input)
$post = [];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertSame('The amount field is required.', $json['errors']['amount'][0]);
$this->assertSame('The balance field is required.', $json['errors']['balance'][0]);
// Test creating a mandate (invalid input)
$post = ['amount' => 100, 'balance' => 'a'];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame('The balance must be a number.', $json['errors']['balance'][0]);
// Test creating a mandate (invalid input)
$post = ['amount' => -100, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF';
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Test creating a mandate (negative balance, amount too small)
Wallet::where('id', $wallet->id)->update(['balance' => -2000]);
$post = ['amount' => PaymentProvider::MIN_AMOUNT / 100, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame("The specified amount does not cover the balance on the account.", $json['errors']['amount']);
// Test creating a mandate (valid input)
$post = ['amount' => 20.10, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertRegExp('|^cs_test_|', $json['id']);
// Assert the proper payment amount has been used
// Stripe in 'setup' mode does not allow to set the amount
$payment = Payment::where('wallet_id', $wallet->id)->first();
$this->assertSame(0, $payment->amount);
- $this->assertSame("Kolab Now Auto-Payment Setup", $payment->description);
+ $this->assertSame(\config('app.name') . " Auto-Payment Setup", $payment->description);
$this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type);
// Test fetching the mandate information
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertSame(false, $json['isDisabled']);
// We would have to invoke a browser to accept the "first payment" to make
// the mandate validated/completed. Instead, we'll mock the mandate object.
$setupIntent = '{
"id": "AAA",
"object": "setup_intent",
"created": 123456789,
"payment_method": "pm_YYY",
"status": "succeeded",
"usage": "off_session",
"customer": null
}';
$paymentMethod = '{
"id": "pm_YYY",
"object": "payment_method",
"card": {
"brand": "visa",
"country": "US",
"last4": "4242"
},
"created": 123456789,
"type": "card"
}';
$client = $this->mockStripe();
$client->addResponse($setupIntent);
$client->addResponse($paymentMethod);
// As we do not use checkout page, we do not receive a webworker request
// I.e. we have to fake the mandate id
$wallet = $user->wallets()->first();
$wallet->setSetting('stripe_mandate_id', 'AAA');
$wallet->setSetting('mandate_disabled', 1);
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertEquals('Visa (**** **** **** 4242)', $json['method']);
$this->assertSame(false, $json['isPending']);
$this->assertSame(true, $json['isValid']);
$this->assertSame(true, $json['isDisabled']);
// Test updating mandate details (invalid input)
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = 1000;
$wallet->save();
$user->refresh();
$post = [];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertSame('The amount field is required.', $json['errors']['amount'][0]);
$this->assertSame('The balance field is required.', $json['errors']['balance'][0]);
$post = ['amount' => -100, 'balance' => 0];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Test updating a mandate (valid input)
$client->addResponse($setupIntent);
$client->addResponse($paymentMethod);
$post = ['amount' => 30.10, 'balance' => 10];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been updated.', $json['message']);
$this->assertEquals(30.10, $wallet->getSetting('mandate_amount'));
$this->assertEquals(10, $wallet->getSetting('mandate_balance'));
$this->assertSame('AAA', $json['id']);
$this->assertFalse($json['isDisabled']);
// Test updating a disabled mandate (invalid input)
$wallet->setSetting('mandate_disabled', 1);
$wallet->balance = -2000;
$wallet->save();
$user->refresh(); // required so the controller sees the wallet update from above
$post = ['amount' => 15.10, 'balance' => 1];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']);
// Test updating a disabled mandate (valid input)
$client->addResponse($setupIntent);
$client->addResponse($paymentMethod);
$post = ['amount' => 30, 'balance' => 1];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been updated.', $json['message']);
$this->assertSame('AAA', $json['id']);
$this->assertFalse($json['isDisabled']);
Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1);
Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) {
$job_wallet = $this->getObjectProperty($job, 'wallet');
return $job_wallet->id === $wallet->id;
});
$this->unmockStripe();
// TODO: Delete mandate
}
/**
* Test creating a payment and receiving a status via webhook
*
* @group stripe
*/
public function testStoreAndWebhook(): void
{
Bus::fake();
// Unauth access not allowed
$response = $this->post("api/v4/payments", []);
$response->assertStatus(401);
$user = $this->getTestUser('john@kolab.org');
$post = ['amount' => -1];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF';
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
- $post = ['amount' => '12.34'];
+
+ // Invalid currency
+ $post = ['amount' => '12.34', 'currency' => 'FOO', 'methodId' => 'creditcard'];
+ $response = $this->actingAs($user)->post("api/v4/payments", $post);
+ $response->assertStatus(500);
+
+ // Successful payment
+ $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertRegExp('|^cs_test_|', $json['id']);
$wallet = $user->wallets()->first();
$payments = Payment::where('wallet_id', $wallet->id)->get();
$this->assertCount(1, $payments);
$payment = $payments[0];
$this->assertSame(1234, $payment->amount);
$this->assertSame(\config('app.name') . ' Payment', $payment->description);
$this->assertSame('open', $payment->status);
$this->assertEquals(0, $wallet->balance);
// Test the webhook
$post = [
'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa",
'object' => "event",
'api_version' => "2020-03-02",
'created' => 1590147209,
'data' => [
'object' => [
'id' => $payment->id,
'object' => "payment_intent",
'amount' => 1234,
'amount_capturable' => 0,
'amount_received' => 1234,
'capture_method' => "automatic",
'client_secret' => "pi_1GlZ7w4fj3SIEU8w1RlBpN4l_secret_UYRNDTUUU7nkYHpOLZMb3uf48",
'confirmation_method' => "automatic",
'created' => 1590147204,
'currency' => "chf",
'customer' => "cus_HKDZ53OsKdlM83",
'last_payment_error' => null,
'livemode' => false,
'metadata' => [],
'receipt_email' => "payment-test@kolabnow.com",
'status' => "succeeded"
]
],
'type' => "payment_intent.succeeded"
];
// Test payment succeeded event
$response = $this->webhookRequest($post);
$response->assertStatus(200);
$this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(1234, $transaction->amount);
$this->assertSame(
"Payment transaction {$payment->id} using Stripe",
$transaction->description
);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
// Test that balance didn't change if the same event is posted
$response = $this->webhookRequest($post);
$response->assertStatus(200);
$this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Test for payment failure ('failed' status)
$payment->refresh();
$payment->status = PaymentProvider::STATUS_OPEN;
$payment->save();
$post['type'] = "payment_intent.payment_failed";
$post['data']['object']['status'] = 'failed';
$response = $this->webhookRequest($post);
$response->assertStatus(200);
$this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
// Test for payment failure ('canceled' status)
$payment->refresh();
$payment->status = PaymentProvider::STATUS_OPEN;
$payment->save();
$post['type'] = "payment_intent.canceled";
$post['data']['object']['status'] = 'canceled';
$response = $this->webhookRequest($post);
$response->assertStatus(200);
$this->assertSame(PaymentProvider::STATUS_CANCELED, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
}
/**
* Test receiving webhook request for setup intent
*
* @group stripe
*/
public function testCreateMandateAndWebhook(): void
{
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
Wallet::where('id', $wallet->id)->update(['balance' => -1000]);
// Test creating a mandate (valid input)
$post = ['amount' => 20.10, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$payment = $wallet->payments()->first();
$this->assertSame(PaymentProvider::STATUS_OPEN, $payment->status);
$this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type);
$this->assertSame(0, $payment->amount);
$post = [
'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa",
'object' => "event",
'api_version' => "2020-03-02",
'created' => 1590147209,
'data' => [
'object' => [
'id' => $payment->id,
'object' => "setup_intent",
'client_secret' => "pi_1GlZ7w4fj3SIEU8w1RlBpN4l_secret_UYRNDTUUU7nkYHpOLZMb3uf48",
'created' => 1590147204,
'customer' => "cus_HKDZ53OsKdlM83",
'last_setup_error' => null,
'metadata' => [],
'status' => "succeeded"
]
],
'type' => "setup_intent.succeeded"
];
Bus::fake();
// Test payment succeeded event
$response = $this->webhookRequest($post);
$response->assertStatus(200);
$payment->refresh();
$this->assertSame(PaymentProvider::STATUS_PAID, $payment->status);
$this->assertSame($payment->id, $wallet->fresh()->getSetting('stripe_mandate_id'));
// Expect a WalletCharge job if the balance is negative
Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1);
Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) {
$job_wallet = TestCase::getObjectProperty($job, 'wallet');
return $job_wallet->id === $wallet->id;
});
// TODO: test other setup_intent.* events
}
/**
* Test automatic payment charges
*
* @group stripe
*/
public function testTopUpAndWebhook(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Stripe API does not allow us to create a mandate easily
// That's why we we'll mock API responses
// Create a fake mandate
$wallet->setSettings([
'mandate_amount' => 20.10,
'mandate_balance' => 10,
'stripe_mandate_id' => 'AAA',
]);
$setupIntent = json_encode([
"id" => "AAA",
"object" => "setup_intent",
"created" => 123456789,
"payment_method" => "pm_YYY",
"status" => "succeeded",
"usage" => "off_session",
"customer" => null
]);
$paymentMethod = json_encode([
"id" => "pm_YYY",
"object" => "payment_method",
"card" => [
"brand" => "visa",
"country" => "US",
"last4" => "4242"
],
"created" => 123456789,
"type" => "card"
]);
$paymentIntent = json_encode([
"id" => "pi_XX",
"object" => "payment_intent",
"created" => 123456789,
"amount" => 2010,
"currency" => "chf",
"description" => "Kolab Recurring Payment"
]);
$client = $this->mockStripe();
$client->addResponse($setupIntent);
$client->addResponse($paymentMethod);
$client->addResponse($setupIntent);
$client->addResponse($paymentIntent);
$client->addResponse($setupIntent);
$client->addResponse($paymentMethod);
// Expect a recurring payment as we have a valid mandate at this point
$result = PaymentsController::topUpWallet($wallet);
$this->assertTrue($result);
// Check that the payments table contains a new record with proper amount
// There should be two records, one for the first payment and another for
// the recurring payment
$this->assertCount(1, $wallet->payments()->get());
$payment = $wallet->payments()->first();
$this->assertSame(2010, $payment->amount);
$this->assertSame(\config('app.name') . " Recurring Payment", $payment->description);
$this->assertSame("pi_XX", $payment->id);
// Expect no payment if the mandate is disabled
$wallet->setSetting('mandate_disabled', 1);
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(1, $wallet->payments()->get());
// Expect no payment if balance is ok
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = 1000;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(1, $wallet->payments()->get());
// Expect no payment if the top-up amount is not enough
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = -2050;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(1, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) {
$job_wallet = $this->getObjectProperty($job, 'wallet');
return $job_wallet->id === $wallet->id;
});
// Expect no payment if there's no mandate
$wallet->setSetting('mollie_mandate_id', null);
$wallet->balance = 0;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(1, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
$this->unmockStripe();
// Test webhook
$post = [
'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa",
'object' => "event",
'api_version' => "2020-03-02",
'created' => 1590147209,
'data' => [
'object' => [
'id' => $payment->id,
'object' => "payment_intent",
'amount' => 2010,
'capture_method' => "automatic",
'created' => 1590147204,
'currency' => "chf",
'customer' => "cus_HKDZ53OsKdlM83",
'last_payment_error' => null,
'metadata' => [],
'receipt_email' => "payment-test@kolabnow.com",
'status' => "succeeded"
]
],
'type' => "payment_intent.succeeded"
];
// Test payment succeeded event
$response = $this->webhookRequest($post);
$response->assertStatus(200);
$this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(2010, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(2010, $transaction->amount);
$this->assertSame(
"Auto-payment transaction {$payment->id} using Stripe",
$transaction->description
);
// Assert that email notification job has been dispatched
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
Bus::fake();
// Test for payment failure ('failed' status)
$payment->refresh();
$payment->status = PaymentProvider::STATUS_OPEN;
$payment->save();
$wallet->setSetting('mandate_disabled', null);
$post['type'] = "payment_intent.payment_failed";
$post['data']['object']['status'] = 'failed';
$response = $this->webhookRequest($post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status);
$this->assertEquals(2010, $wallet->balance);
$this->assertTrue(!empty($wallet->getSetting('mandate_disabled')));
// Assert that email notification job has been dispatched
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
Bus::fake();
// Test for payment failure ('canceled' status)
$payment->refresh();
$payment->status = PaymentProvider::STATUS_OPEN;
$payment->save();
$post['type'] = "payment_intent.canceled";
$post['data']['object']['status'] = 'canceled';
$response = $this->webhookRequest($post);
$response->assertStatus(200);
$this->assertSame(PaymentProvider::STATUS_CANCELED, $payment->fresh()->status);
$this->assertEquals(2010, $wallet->fresh()->balance);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
}
/**
* Generate Stripe-Signature header for a webhook payload
*/
protected function webhookRequest($post)
{
$secret = \config('services.stripe.webhook_secret');
$ts = time();
$payload = "$ts." . json_encode($post);
$sig = sprintf('t=%d,v1=%s', $ts, \hash_hmac('sha256', $payload, $secret));
return $this->withHeaders(['Stripe-Signature' => $sig])
->json('POST', "api/webhooks/payment/stripe", $post);
}
+
+ /**
+ * Test listing payment methods
+ *
+ * @group stripe
+ */
+ public function testListingPaymentMethods(): void
+ {
+ Bus::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+
+ $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF);
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('creditcard', $json[0]['id']);
+ $this->assertSame('paypal', $json[1]['id']);
+
+ $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_RECURRING);
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $this->assertCount(1, $json);
+ $this->assertSame('creditcard', $json[0]['id']);
+ }
}
diff --git a/src/tests/Feature/Jobs/PaymentEmailTest.php b/src/tests/Feature/Jobs/PaymentEmailTest.php
index 9ef6cfca..b2813a87 100644
--- a/src/tests/Feature/Jobs/PaymentEmailTest.php
+++ b/src/tests/Feature/Jobs/PaymentEmailTest.php
@@ -1,120 +1,122 @@
<?php
namespace Tests\Feature\Jobs;
use App\Jobs\PaymentEmail;
use App\Mail\PaymentFailure;
use App\Mail\PaymentSuccess;
use App\Payment;
use App\Providers\PaymentProvider;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class PaymentEmailTest extends TestCase
{
/**
* {@inheritDoc}
*
* @return void
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('PaymentEmail@UserAccount.com');
}
/**
* {@inheritDoc}
*
* @return void
*/
public function tearDown(): void
{
$this->deleteTestUser('PaymentEmail@UserAccount.com');
parent::tearDown();
}
/**
* Test job handle
*
* @return void
*/
public function testHandle()
{
$user = $this->getTestUser('PaymentEmail@UserAccount.com');
$user->setSetting('external_email', 'ext@email.tld');
$wallet = $user->wallets()->first();
$payment = new Payment();
$payment->id = 'test-payment';
$payment->wallet_id = $wallet->id;
$payment->amount = 100;
+ $payment->currency_amount = 100;
+ $payment->currency = 'CHF';
$payment->status = PaymentProvider::STATUS_PAID;
$payment->description = 'test';
$payment->provider = 'stripe';
$payment->type = PaymentProvider::TYPE_ONEOFF;
$payment->save();
Mail::fake();
// Assert that no jobs were pushed...
Mail::assertNothingSent();
$job = new PaymentEmail($payment);
$job->handle();
// Assert the email sending job was pushed once
Mail::assertSent(PaymentSuccess::class, 1);
// Assert the mail was sent to the user's email
Mail::assertSent(PaymentSuccess::class, function ($mail) {
return $mail->hasTo('ext@email.tld') && !$mail->hasCc('ext@email.tld');
});
$payment->status = PaymentProvider::STATUS_FAILED;
$payment->save();
$job = new PaymentEmail($payment);
$job->handle();
// Assert the email sending job was pushed once
Mail::assertSent(PaymentFailure::class, 1);
// Assert the mail was sent to the user's email
Mail::assertSent(PaymentFailure::class, function ($mail) {
return $mail->hasTo('ext@email.tld') && !$mail->hasCc('ext@email.tld');
});
$payment->status = PaymentProvider::STATUS_EXPIRED;
$payment->save();
$job = new PaymentEmail($payment);
$job->handle();
// Assert the email sending job was pushed twice
Mail::assertSent(PaymentFailure::class, 2);
// None of statuses below should trigger an email
Mail::fake();
$states = [
PaymentProvider::STATUS_OPEN,
PaymentProvider::STATUS_CANCELED,
PaymentProvider::STATUS_PENDING,
PaymentProvider::STATUS_AUTHORIZED,
];
foreach ($states as $state) {
$payment->status = $state;
$payment->save();
$job = new PaymentEmail($payment);
$job->handle();
}
// Assert that no mailables were sent...
Mail::assertNothingSent();
}
}

File Metadata

Mime Type
text/x-diff
Expires
Tue, Feb 3, 2:16 PM (15 h, 32 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
427252
Default Alt Text
(241 KB)

Event Timeline