Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2530740
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
241 KB
Referenced Files
None
Subscribers
None
View Options
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">×</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
Details
Attached
Mime Type
text/x-diff
Expires
Tue, Feb 3, 12:52 PM (1 d, 3 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
427252
Default Alt Text
(241 KB)
Attached To
Mode
R2 kolab
Attached
Detach File
Event Timeline
Log In to Comment