Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
197 KB
Referenced Files
None
Subscribers
None
diff --git a/src/app/Console/Commands/JobExecuteCommand.php b/src/app/Console/Commands/JobExecuteCommand.php
index 6587eaa7..4909624a 100644
--- a/src/app/Console/Commands/JobExecuteCommand.php
+++ b/src/app/Console/Commands/JobExecuteCommand.php
@@ -1,54 +1,52 @@
<?php
namespace App\Console\Commands;
use App\Console\Command;
class JobExecuteCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'job:execute {job} {object}';
/**
* The console command description.
*
* @var string
*/
protected $description = "Execute a job.";
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$job = $this->argument('job');
$object = $this->argument('object');
$job = str_replace('/', '\\', $job);
- if (preg_match('/^(WalletCheck|WalletCharge)$/', $job)) {
- $object = $this->getWallet($object);
- } elseif (preg_match('/^(User|Domain|Group|Resource|SharedFolder).[a-zA-Z]+$/', $job, $m)) {
+ if (preg_match('/^(User|Domain|Group|Resource|SharedFolder|Wallet).[a-zA-Z]+$/', $job, $m)) {
$object = $this->{'get' . $m[1]}($object);
} else {
$this->error("Invalid or unsupported job name.");
return 1;
}
if (empty($object)) {
$this->error("Object not found.");
return 1;
}
$job = 'App\\Jobs\\' . $job;
$job = new $job($object->id);
$job->handle();
}
}
diff --git a/src/app/Console/Commands/Wallet/ChargeCommand.php b/src/app/Console/Commands/Wallet/ChargeCommand.php
index 619694a0..60807aaa 100644
--- a/src/app/Console/Commands/Wallet/ChargeCommand.php
+++ b/src/app/Console/Commands/Wallet/ChargeCommand.php
@@ -1,78 +1,78 @@
<?php
namespace App\Console\Commands\Wallet;
use App\Console\Command;
class ChargeCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'wallet:charge {--topup : Only top-up wallets} {--dry-run} {wallet?}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Charge wallets, and trigger a topup on charged wallets.';
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
if ($wallet = $this->argument('wallet')) {
// Find specified wallet by ID
$wallet = $this->getWallet($wallet);
if (!$wallet) {
$this->error("Wallet not found.");
return 1;
}
if (!$wallet->owner) {
$this->error("Wallet's owner is deleted.");
return 1;
}
$wallets = [$wallet];
} elseif ($this->option('topup')) {
// Find wallets that need to be topped up
$wallets = \App\Wallet::select('wallets.id')
->join('users', 'users.id', '=', 'wallets.user_id')
->join('wallet_settings', function (\Illuminate\Database\Query\JoinClause $join) {
$join->on('wallet_settings.wallet_id', '=', 'wallets.id')
->where('wallet_settings.key', '=', 'mandate_balance');
})
->whereNull('users.deleted_at')
->whereRaw('wallets.balance < (wallet_settings.value * 100)')
->whereNot('users.status', '&', \App\User::STATUS_DEGRADED | \App\User::STATUS_SUSPENDED)
->cursor();
} else {
// Get all wallets, excluding deleted accounts
$wallets = \App\Wallet::select('wallets.id')
->join('users', 'users.id', '=', 'wallets.user_id')
->whereNull('users.deleted_at')
->cursor();
}
foreach ($wallets as $wallet) {
if ($this->option('dry-run')) {
$this->info($wallet->id);
} else {
if ($this->option('topup')) {
$this->info("Dispatching wallet charge for {$wallet->id}");
- \App\Jobs\WalletCharge::dispatch($wallet->id);
+ \App\Jobs\Wallet\ChargeJob::dispatch($wallet->id);
} else {
- \App\Jobs\WalletCheck::dispatch($wallet->id);
+ \App\Jobs\Wallet\CheckJob::dispatch($wallet->id);
}
}
}
}
}
diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php
index 3abb8e41..4924e740 100644
--- a/src/app/Http/Controllers/API/V4/PaymentsController.php
+++ b/src/app/Http/Controllers/API/V4/PaymentsController.php
@@ -1,507 +1,507 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\Payment;
use App\Providers\PaymentProvider;
use App\Tenant;
use App\Wallet;
use Illuminate\Http\Request;
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 = $this->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)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $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' => $wallet->currency,
'description' => Tenant::getConfig($user->tenant_id, 'app.name')
. ' ' . self::trans('app.mandate-description-suffix'),
'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD,
];
// 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 < round($request->balance * 100)) {
$mandate['amount'] = (int) round($request->amount * 100);
$mandate = $wallet->paymentRequest($mandate);
}
$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 = $this->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' => self::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)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $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 < round($request->balance * 100)) {
- \App\Jobs\WalletCharge::dispatch($wallet->id);
+ \App\Jobs\Wallet\ChargeJob::dispatch($wallet->id);
}
$result = self::walletMandate($wallet);
$result['status'] = 'success';
$result['message'] = self::trans('app.mandate-update-success');
return response()->json($result);
}
/**
* Reset the auto-payment mandate, create a new payment for it.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateReset(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$mandate = [
'currency' => $wallet->currency,
'description' => Tenant::getConfig($user->tenant_id, 'app.name')
. ' ' . self::trans('app.mandate-description-suffix'),
'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD,
'redirectUrl' => \App\Utils::serviceUrl('/payment/status', $user->tenant_id),
];
$provider = PaymentProvider::factory($wallet);
$result = $provider->createMandate($wallet, $mandate);
$result['status'] = '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) round($request->amount * 100);
// Validate the minimum value
// It has to be at least minimum payment amount and must cover current debt,
// and must be more than a yearly/monthly payment (according to the plan)
$min = $wallet->getMinMandateAmount();
$label = 'minamount';
if ($wallet->balance < 0 && $wallet->balance < $min * -1) {
$min = $wallet->balance * -1;
$label = 'minamountdebt';
}
if ($amount < $min) {
return ['amount' => self::trans("validation.{$label}", ['amount' => $wallet->money($min)])];
}
return null;
}
/**
* Get status of the last payment.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function paymentStatus()
{
$user = $this->guard()->user();
$wallet = $user->wallets()->first();
$payment = $wallet->payments()->orderBy('created_at', 'desc')->first();
if (empty($payment)) {
return $this->errorResponse(404);
}
$done = [Payment::STATUS_PAID, Payment::STATUS_CANCELED, Payment::STATUS_FAILED, Payment::STATUS_EXPIRED];
if (in_array($payment->status, $done)) {
$label = "app.payment-status-{$payment->status}";
} else {
$label = "app.payment-status-checking";
}
return response()->json([
'id' => $payment->id,
'status' => $payment->status,
'type' => $payment->type,
'statusMessage' => self::trans($label),
'description' => $payment->description,
]);
}
/**
* Create a new payment.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $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) round($request->amount * 100);
// Validate the minimum value
if ($amount < Payment::MIN_AMOUNT) {
$min = $wallet->money(Payment::MIN_AMOUNT);
$errors = ['amount' => self::trans('validation.minamount', ['amount' => $min])];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$currency = $request->currency;
$request = $wallet->paymentRequest([
'type' => Payment::TYPE_ONEOFF,
'currency' => $currency,
'amount' => $amount,
'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD,
'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Payment',
]);
$provider = PaymentProvider::factory($wallet, $currency);
$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)
// {
// $user = $this->guard()->user();
// // TODO: Wallet selection
// $wallet = $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);
}
/**
* 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);
$settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']);
// Get the Mandate info
$mandate = (array) $provider->getMandate($wallet);
$mandate['amount'] = $mandate['minAmount'] = round($wallet->getMinMandateAmount() / 100, 2);
$mandate['balance'] = 0;
$mandate['isDisabled'] = !empty($mandate['id']) && $settings['mandate_disabled'];
$mandate['isValid'] = !empty($mandate['isValid']);
foreach (['amount', 'balance'] as $key) {
if (($value = $settings["mandate_{$key}"]) !== null) {
$mandate[$key] = $value;
}
}
// Unrestrict the wallet owner if mandate is valid
if (!empty($mandate['isValid']) && $wallet->owner->isRestricted()) {
$wallet->owner->unrestrict();
}
return $mandate;
}
/**
* List supported payment methods.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function paymentMethods(Request $request)
{
$user = $this->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 function hasPayments(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$exists = Payment::where('wallet_id', $wallet->id)
->where('type', Payment::TYPE_ONEOFF)
->whereIn('status', [
Payment::STATUS_OPEN,
Payment::STATUS_PENDING,
Payment::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 function payments(Request $request)
{
$user = $this->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', Payment::TYPE_ONEOFF)
->whereIn('status', [
Payment::STATUS_OPEN,
Payment::STATUS_PENDING,
Payment::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) use ($wallet) {
$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,
'currency' => $wallet->currency,
// note: $item->currency/$item->currency_amount might be different
'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/Jobs/WalletCharge.php b/src/app/Jobs/Wallet/ChargeJob.php
similarity index 90%
rename from src/app/Jobs/WalletCharge.php
rename to src/app/Jobs/Wallet/ChargeJob.php
index c77aaccd..6746c498 100644
--- a/src/app/Jobs/WalletCharge.php
+++ b/src/app/Jobs/Wallet/ChargeJob.php
@@ -1,43 +1,44 @@
<?php
-namespace App\Jobs;
+namespace App\Jobs\Wallet;
+use App\Jobs\CommonJob;
use App\Wallet;
-class WalletCharge extends CommonJob
+class ChargeJob extends CommonJob
{
/** @var int How many times retry the job if it fails. */
public $tries = 5;
/** @var string|null The name of the queue the job should be sent to. */
public $queue = \App\Enums\Queue::Background->value;
/** @var string A wallet identifier */
protected $walletId;
/**
* Create a new job instance.
*
* @param string $walletId The wallet that has been charged.
*
* @return void
*/
public function __construct(string $walletId)
{
$this->walletId = $walletId;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$this->logJobStart($this->walletId);
if ($wallet = Wallet::find($this->walletId)) {
$wallet->topUp();
}
}
}
diff --git a/src/app/Jobs/WalletCheck.php b/src/app/Jobs/Wallet/CheckJob.php
similarity index 96%
rename from src/app/Jobs/WalletCheck.php
rename to src/app/Jobs/Wallet/CheckJob.php
index ef26a369..b6a4973a 100644
--- a/src/app/Jobs/WalletCheck.php
+++ b/src/app/Jobs/Wallet/CheckJob.php
@@ -1,264 +1,265 @@
<?php
-namespace App\Jobs;
+namespace App\Jobs\Wallet;
+use App\Jobs\CommonJob;
use App\Wallet;
use Carbon\Carbon;
-class WalletCheck extends CommonJob
+class CheckJob extends CommonJob
{
public const THRESHOLD_DEGRADE = 'degrade';
public const THRESHOLD_DEGRADE_REMINDER = 'degrade-reminder';
public const THRESHOLD_REMINDER = 'reminder';
public const THRESHOLD_INITIAL = 'initial';
/** @var int How many times retry the job if it fails. */
public $tries = 5;
/** @var string|null The name of the queue the job should be sent to. */
public $queue = \App\Enums\Queue::Background->value;
/** @var ?Wallet A wallet object */
protected $wallet;
/** @var string A wallet identifier */
protected $walletId;
/**
* Create a new job instance.
*
* @param string $walletId The wallet that has been charged.
*
* @return void
*/
public function __construct(string $walletId)
{
$this->walletId = $walletId;
}
/**
* Execute the job.
*
* @return ?string Executed action (THRESHOLD_*)
*/
public function handle()
{
$this->logJobStart($this->walletId);
$this->wallet = Wallet::find($this->walletId);
// Sanity check (owner deleted in meantime)
if (!$this->wallet || !$this->wallet->owner) {
\Log::warning(
"[WalletCheck] The wallet has been deleted in the meantime or doesn't have an owner {$this->walletId}."
);
return null;
}
$this->wallet->chargeEntitlements();
try {
$this->wallet->topUp();
} catch (\Exception $e) {
\Log::error("Failed to top-up wallet {$this->walletId}: " . $e->getMessage());
// Notification emails should be sent even if the top-up fails
}
if ($this->wallet->balance >= 0) {
return null;
}
$now = Carbon::now();
$steps = [
// Send the initial reminder
self::THRESHOLD_INITIAL => 'initialReminderForDegrade',
// Send the second reminder
self::THRESHOLD_REMINDER => 'secondReminderForDegrade',
// Degrade the account
self::THRESHOLD_DEGRADE => 'degradeAccount',
];
if ($this->wallet->owner->isDegraded()) {
$this->degradedReminder();
return self::THRESHOLD_DEGRADE_REMINDER;
}
foreach (array_reverse($steps, true) as $type => $method) {
if (self::threshold($this->wallet, $type) < $now) {
$this->{$method}();
return $type;
}
}
return null;
}
/**
* Send the initial reminder (for the process of degrading a account)
*/
protected function initialReminderForDegrade()
{
if ($this->wallet->getSetting('balance_warning_initial')) {
return;
}
if ($this->wallet->owner->isDegraded()) {
return;
}
if (!$this->wallet->owner->isSuspended()) {
$this->sendMail(\App\Mail\NegativeBalance::class, false);
}
$now = \Carbon\Carbon::now()->toDateTimeString();
$this->wallet->setSetting('balance_warning_initial', $now);
}
/**
* Send the second reminder (for the process of degrading a account)
*/
protected function secondReminderForDegrade()
{
if ($this->wallet->getSetting('balance_warning_reminder')) {
return;
}
if ($this->wallet->owner->isDegraded()) {
return;
}
if (!$this->wallet->owner->isSuspended()) {
$this->sendMail(\App\Mail\NegativeBalanceReminderDegrade::class, true);
}
$now = \Carbon\Carbon::now()->toDateTimeString();
$this->wallet->setSetting('balance_warning_reminder', $now);
}
/**
* Degrade the account
*/
protected function degradeAccount()
{
// The account may be already deleted, or degraded
if ($this->wallet->owner->isDegraded()) {
return;
}
$email = $this->wallet->owner->email;
// The dirty work will be done by UserObserver
$this->wallet->owner->degrade();
\Log::info(
sprintf(
"[WalletCheck] Account degraded %s (%s)",
$this->wallet->id,
$email
)
);
if (!$this->wallet->owner->isSuspended()) {
$this->sendMail(\App\Mail\NegativeBalanceDegraded::class, true);
}
}
/**
* Send the periodic reminder to the degraded account owners
*/
protected function degradedReminder()
{
// Sanity check
if (!$this->wallet->owner->isDegraded()) {
return;
}
if ($this->wallet->owner->isSuspended()) {
return;
}
$now = \Carbon\Carbon::now();
$last = $this->wallet->getSetting('degraded_last_reminder');
if ($last) {
$last = new Carbon($last);
$period = 14;
if ($last->addDays($period) > $now) {
return;
}
$this->sendMail(\App\Mail\DegradedAccountReminder::class, false);
}
$this->wallet->setSetting('degraded_last_reminder', $now->toDateTimeString());
}
/**
* Send the email
*
* @param string $class Mailable class name
* @param bool $with_external Use users's external email
*/
protected function sendMail($class, $with_external = false): void
{
// TODO: Send the email to all wallet controllers?
$mail = new $class($this->wallet, $this->wallet->owner);
list($to, $cc) = \App\Mail\Helper::userEmails($this->wallet->owner, $with_external);
if (!empty($to) || !empty($cc)) {
$params = [
'to' => $to,
'cc' => $cc,
'add' => " for {$this->wallet->id}",
];
\App\Mail\Helper::sendMail($mail, $this->wallet->owner->tenant_id, $params);
}
}
/**
* Get the date-time for an action threshold. Calculated using
* the date when a wallet balance turned negative.
*
- * @param \App\Wallet $wallet A wallet
- * @param string $type Action type (one of self::THRESHOLD_*)
+ * @param Wallet $wallet A wallet
+ * @param string $type Action type (one of self::THRESHOLD_*)
*
- * @return \Carbon\Carbon The threshold date-time object
+ * @return Carbon The threshold date-time object
*/
public static function threshold(Wallet $wallet, string $type): ?Carbon
{
$negative_since = $wallet->getSetting('balance_negative_since');
// Migration scenario: balance<0, but no balance_negative_since set
if (!$negative_since) {
// 2h back from now, so first run can sent the initial notification
$negative_since = Carbon::now()->subHours(2);
$wallet->setSetting('balance_negative_since', $negative_since->toDateTimeString());
} else {
$negative_since = new Carbon($negative_since);
}
// Initial notification
// Give it an hour so the async recurring payment has a chance to be finished
if ($type == self::THRESHOLD_INITIAL) {
return $negative_since->addHours(1);
}
$thresholds = [
// Second notification
self::THRESHOLD_REMINDER => 7,
// Account degradation
self::THRESHOLD_DEGRADE => 14,
];
if (!empty($thresholds[$type])) {
return $negative_since->addDays($thresholds[$type]);
}
return null;
}
}
diff --git a/src/app/Mail/NegativeBalanceDegraded.php b/src/app/Mail/NegativeBalanceDegraded.php
index 44261f3c..61b48435 100644
--- a/src/app/Mail/NegativeBalanceDegraded.php
+++ b/src/app/Mail/NegativeBalanceDegraded.php
@@ -1,77 +1,76 @@
<?php
namespace App\Mail;
-use App\Jobs\WalletCheck;
use App\Tenant;
use App\User;
use App\Utils;
use App\Wallet;
class NegativeBalanceDegraded extends Mailable
{
- /** @var \App\Wallet A wallet with a negative balance */
+ /** @var Wallet A wallet with a negative balance */
protected $wallet;
/**
* Create a new message instance.
*
- * @param \App\Wallet $wallet A wallet
- * @param \App\User $user A wallet controller to whom the email is being sent
+ * @param Wallet $wallet A wallet
+ * @param User $user A wallet controller to whom the email is being sent
*
* @return void
*/
public function __construct(Wallet $wallet, User $user)
{
$this->wallet = $wallet;
$this->user = $user;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$appName = Tenant::getConfig($this->user->tenant_id, 'app.name');
$supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url');
$vars = [
'email' => $this->wallet->owner->email,
'name' => $this->user->name(true),
'site' => $appName,
];
$this->view('emails.html.negative_balance_degraded')
->text('emails.plain.negative_balance_degraded')
->subject(\trans('mail.negativebalancedegraded-subject', $vars))
->with([
'vars' => $vars,
'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id),
'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id),
]);
return $this;
}
/**
* Render the mail template with fake data
*
* @param string $type Output format ('html' or 'text')
*
* @return string HTML or Plain Text output
*/
public static function fakeRender(string $type = 'html'): string
{
$wallet = new Wallet();
$user = new User();
$user->email = 'test@' . \config('app.domain');
$wallet->owner = $user;
$mail = new self($wallet, $user);
return Helper::render($mail, $type);
}
}
diff --git a/src/app/Mail/NegativeBalanceReminderDegrade.php b/src/app/Mail/NegativeBalanceReminderDegrade.php
index 2ec4caad..5514ddec 100644
--- a/src/app/Mail/NegativeBalanceReminderDegrade.php
+++ b/src/app/Mail/NegativeBalanceReminderDegrade.php
@@ -1,79 +1,79 @@
<?php
namespace App\Mail;
-use App\Jobs\WalletCheck;
+use App\Jobs\Wallet\CheckJob;
use App\Tenant;
use App\User;
use App\Utils;
use App\Wallet;
class NegativeBalanceReminderDegrade extends Mailable
{
/** @var \App\Wallet A wallet with a negative balance */
protected $wallet;
/**
* Create a new message instance.
*
* @param \App\Wallet $wallet A wallet
* @param \App\User $user A wallet controller to whom the email is being sent
*
* @return void
*/
public function __construct(Wallet $wallet, User $user)
{
$this->wallet = $wallet;
$this->user = $user;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$appName = Tenant::getConfig($this->user->tenant_id, 'app.name');
$supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url');
- $threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_DEGRADE);
+ $threshold = CheckJob::threshold($this->wallet, CheckJob::THRESHOLD_DEGRADE);
$vars = [
'date' => $threshold->toDateString(),
'email' => $this->wallet->owner->email,
'name' => $this->user->name(true),
'site' => $appName,
];
$this->view('emails.html.negative_balance_reminder_degrade')
->text('emails.plain.negative_balance_reminder_degrade')
->subject(\trans('mail.negativebalancereminderdegrade-subject', $vars))
->with([
'vars' => $vars,
'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id),
'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id),
]);
return $this;
}
/**
* Render the mail template with fake data
*
* @param string $type Output format ('html' or 'text')
*
* @return string HTML or Plain Text output
*/
public static function fakeRender(string $type = 'html'): string
{
$wallet = new Wallet();
$user = new User();
$user->email = 'test@' . \config('app.domain');
$wallet->owner = $user;
$mail = new self($wallet, $user);
return Helper::render($mail, $type);
}
}
diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php
index b46a7b18..b9fd3a8f 100644
--- a/src/app/Providers/Payment/Stripe.php
+++ b/src/app/Providers/Payment/Stripe.php
@@ -1,555 +1,555 @@
<?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.
* - redirectUrl: The location to goto after checkout
*
* @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' => $payment['redirectUrl'] ?? self::redirectUrl(), // required
'success_url' => $payment['redirectUrl'] ?? self::redirectUrl(), // 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
+ // We'll dispatch Wallet\ChargeJob when we receive a webhook request
$session = StripeAPI\Checkout\Session::create($request);
$payment['amount'] = 0;
$payment['credit_amount'] = 0;
$payment['currency_amount'] = 0;
$payment['vat_rate_id'] = null;
$payment['id'] = $session->setup_intent;
$payment['type'] = Payment::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'] == Payment::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' => self::redirectUrl(), // required
'success_url' => self::redirectUrl(), // required
'payment_method_types' => ['card'], // required
'locale' => 'en',
'line_items' => [
[
'name' => $payment['description'],
'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' => $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 == Payment::TYPE_MANDATE) {
return 404;
}
switch ($intent->status) {
case StripeAPI\PaymentIntent::STATUS_CANCELED:
$status = Payment::STATUS_CANCELED;
break;
case StripeAPI\PaymentIntent::STATUS_SUCCEEDED:
$status = Payment::STATUS_PAID;
break;
default:
$status = Payment::STATUS_FAILED;
}
DB::beginTransaction();
if ($status == Payment::STATUS_PAID) {
// Update the balance, if it wasn't already
if ($payment->status != Payment::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 != Payment::STATUS_PAID) {
$payment->status = $status;
$payment->save();
if ($status != Payment::STATUS_CANCELED && $payment->type == Payment::TYPE_RECURRING) {
// Disable the mandate
if ($status == Payment::STATUS_FAILED) {
$payment->wallet->setSetting('mandate_disabled', '1');
}
// Notify the user
\App\Jobs\Mail\PaymentJob::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 != Payment::TYPE_MANDATE) {
return 404;
}
switch ($intent->status) {
case StripeAPI\SetupIntent::STATUS_CANCELED:
$status = Payment::STATUS_CANCELED;
break;
case StripeAPI\SetupIntent::STATUS_SUCCEEDED:
$status = Payment::STATUS_PAID;
break;
default:
$status = Payment::STATUS_FAILED;
}
if ($status == Payment::STATUS_PAID) {
$payment->wallet->setSetting('stripe_mandate_id', $intent->id);
$threshold = (int) round((float) $payment->wallet->getSetting('mandate_balance') * 100);
// Call credit() so wallet/account state is updated
$this->creditPayment($payment, $intent);
// Top-up the wallet if balance is below the threshold
if ($payment->wallet->balance < $threshold && $payment->status != Payment::STATUS_PAID) {
- \App\Jobs\WalletCharge::dispatch($payment->wallet->id);
+ \App\Jobs\Wallet\ChargeJob::dispatch($payment->wallet->id);
}
}
$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);
}
$payment->credit($method);
}
/**
* 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).
* @param string $currency Currency code
*
* @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(string $type, string $currency): array
{
//TODO get this from the stripe API?
$availableMethods = [];
switch ($type) {
case Payment::TYPE_ONEOFF:
$availableMethods = [
self::METHOD_CREDITCARD => [
'id' => self::METHOD_CREDITCARD,
'name' => "Credit Card",
'minimumAmount' => Payment::MIN_AMOUNT,
'currency' => $currency,
'exchangeRate' => 1.0
],
self::METHOD_PAYPAL => [
'id' => self::METHOD_PAYPAL,
'name' => "PayPal",
'minimumAmount' => Payment::MIN_AMOUNT,
'currency' => $currency,
'exchangeRate' => 1.0
]
];
break;
case Payment::TYPE_RECURRING:
$availableMethods = [
self::METHOD_CREDITCARD => [
'id' => self::METHOD_CREDITCARD,
'name' => "Credit Card",
'minimumAmount' => Payment::MIN_AMOUNT, // Converted to cents,
'currency' => $currency,
'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/tests/Feature/Console/Wallet/ChargeTest.php b/src/tests/Feature/Console/Wallet/ChargeTest.php
index fa5c5865..93724661 100644
--- a/src/tests/Feature/Console/Wallet/ChargeTest.php
+++ b/src/tests/Feature/Console/Wallet/ChargeTest.php
@@ -1,89 +1,89 @@
<?php
namespace Tests\Feature\Console\Wallet;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class ChargeTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('wallet-charge@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('wallet-charge@kolabnow.com');
parent::tearDown();
}
/**
* Test command run for a specified wallet
*/
public function testHandleSingle(): void
{
$user = $this->getTestUser('wallet-charge@kolabnow.com');
$wallet = $user->wallets()->first();
$wallet->balance = 0;
$wallet->save();
Queue::fake();
// Non-existing wallet ID
$this->artisan('wallet:charge 123')
->assertExitCode(1)
->expectsOutput("Wallet not found.");
Queue::assertNothingPushed();
// The wallet has no entitlements, expect no charge and no check
$this->artisan('wallet:charge ' . $wallet->id)
->assertExitCode(0);
- Queue::assertPushed(\App\Jobs\WalletCheck::class, 1);
- Queue::assertPushed(\App\Jobs\WalletCheck::class, function ($job) use ($wallet) {
+ Queue::assertPushed(\App\Jobs\Wallet\CheckJob::class, 1);
+ Queue::assertPushed(\App\Jobs\Wallet\CheckJob::class, function ($job) use ($wallet) {
$job_wallet_id = TestCase::getObjectProperty($job, 'walletId');
return $job_wallet_id === $wallet->id;
});
}
/**
* Test command run for all wallets
*/
public function testHandleAll(): void
{
$user1 = $this->getTestUser('john@kolab.org');
$wallet1 = $user1->wallets()->first();
$user2 = $this->getTestUser('wallet-charge@kolabnow.com');
$wallet2 = $user2->wallets()->first();
$count = \App\Wallet::join('users', 'users.id', '=', 'wallets.user_id')
->whereNull('users.deleted_at')
->count();
Queue::fake();
$this->artisan('wallet:charge')->assertExitCode(0);
- Queue::assertPushed(\App\Jobs\WalletCheck::class, $count);
- Queue::assertPushed(\App\Jobs\WalletCheck::class, function ($job) use ($wallet1) {
+ Queue::assertPushed(\App\Jobs\Wallet\CheckJob::class, $count);
+ Queue::assertPushed(\App\Jobs\Wallet\CheckJob::class, function ($job) use ($wallet1) {
$job_wallet_id = TestCase::getObjectProperty($job, 'walletId');
return $job_wallet_id === $wallet1->id;
});
- Queue::assertPushed(\App\Jobs\WalletCheck::class, function ($job) use ($wallet2) {
+ Queue::assertPushed(\App\Jobs\Wallet\CheckJob::class, function ($job) use ($wallet2) {
$job_wallet_id = TestCase::getObjectProperty($job, 'walletId');
return $job_wallet_id === $wallet2->id;
});
}
}
diff --git a/src/tests/Feature/Controller/PaymentsMollieEuroTest.php b/src/tests/Feature/Controller/PaymentsMollieEuroTest.php
index 33f8a523..59fb57a9 100644
--- a/src/tests/Feature/Controller/PaymentsMollieEuroTest.php
+++ b/src/tests/Feature/Controller/PaymentsMollieEuroTest.php
@@ -1,905 +1,905 @@
<?php
namespace Tests\Feature\Controller;
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 Illuminate\Support\Facades\Http;
use Tests\TestCase;
use Tests\BrowserAddonTrait;
class PaymentsMollieEuroTest extends TestCase
{
use BrowserAddonTrait;
protected const API_URL = 'https://api.mollie.com/v2';
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
if (!\config('services.mollie.key')) {
$this->markTestSkipped('No MOLLIE_KEY');
}
// All tests in this file use Mollie
\config(['services.payment_provider' => 'mollie']);
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
if (\config('services.mollie.key')) {
$this->deleteTestUser('euro@' . \config('app.domain'));
}
parent::tearDown();
}
/**
* Test creating/updating/deleting an outo-payment mandate
*
* @group mollie
* @group slow
*/
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('euro@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->currency = 'EUR';
$wallet->save();
// 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 = $wallet->money(Payment::MIN_AMOUNT);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
$this->assertMatchesRegularExpression("/[0-9.,]+ €\.$/", $json['errors']['amount']);
// Test creating a mandate (negative balance, amount too small)
Wallet::where('id', $wallet->id)->update(['balance' => -2000]);
$post = ['amount' => Payment::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)
$json = $this->createMollieMandate(
$wallet->fresh(),
['amount' => 20.10, 'balance' => 0, 'methodId' => PaymentProvider::METHOD_CREDITCARD]
);
$mandate_id = $json['mandateId'];
// 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($user->tenant->title . " Auto-Payment Setup", $payment->description);
$this->assertSame(Payment::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->assertTrue(in_array($json['method'], ['Mastercard (**** **** **** 9399)', 'Credit Card']));
$this->assertSame(false, $json['isPending']);
$this->assertSame(true, $json['isValid']);
$this->assertSame(false, $json['isDisabled']);
$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->assertTrue(in_array($json['method'], ['Mastercard (**** **** **** 9399)', 'Credit Card']));
$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']);
$this->assertMatchesRegularExpression("/[0-9.,]+ €\.$/", $json['errors']['amount']);
// Test updating a mandate (valid input)
$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);
+ Bus::assertDispatchedTimes(\App\Jobs\Wallet\ChargeJob::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)
$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) {
+ Bus::assertDispatchedTimes(\App\Jobs\Wallet\ChargeJob::class, 1);
+ Bus::assertDispatched(\App\Jobs\Wallet\ChargeJob::class, function ($job) use ($wallet) {
$job_wallet_id = $this->getObjectProperty($job, 'walletId');
return $job_wallet_id === $wallet->id;
});
// 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 the mandate does not exist (is not valid) anymore
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isValid']);
$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"
]
]
];
$mollieId = $wallet->getSetting('mollie_id');
Http::fakeClear()->fake([
self::API_URL . "/customers/{$mollieId}/mandates/123" => Http::response($mollie_response, 410, []),
]);
$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);
$user = $this->getTestUser('euro@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->currency = 'EUR';
$wallet->save();
// Invalid amount
$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 = $wallet->money(Payment::MIN_AMOUNT);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
$this->assertMatchesRegularExpression("/[0-9.,]+ €\.$/", $json['errors']['amount']);
// 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' => 'EUR', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']);
$payments = Payment::where('wallet_id', $wallet->id)->get();
$this->assertCount(1, $payments);
$payment = $payments[0];
$this->assertSame(1234, $payment->amount);
$this->assertSame(1234, $payment->currency_amount);
$this->assertSame('EUR', $payment->currency);
$this->assertSame($user->tenant->title . ' 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.
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response, 200, []),
]);
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(Payment::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\Mail\PaymentJob::class, 0);
// Verify "paid -> open -> paid" scenario, assert that balance didn't change
$mollie_response['status'] = 'open';
unset($mollie_response['paidAt']);
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response, 200, []),
]);
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
$mollie_response['status'] = 'paid';
$mollie_response['paidAt'] = date('c');
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response, 200, []),
]);
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Test for payment failure
Bus::fake();
$payment->refresh();
$payment->status = Payment::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.
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response, 200, []),
]);
$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\Mail\PaymentJob::class, 0);
}
/**
* Test automatic payment charges
*
* @group mollie
* @group slow
*/
public function testTopUp(): void
{
Bus::fake();
$user = $this->getTestUser('euro@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->currency = 'EUR';
$wallet->save();
// Create a valid mandate first (balance=0, so there's no extra payment yet)
$this->createMollieMandate($wallet, ['amount' => 20.10, 'balance' => 0, 'methodId' => 'creditcard']);
$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 = $wallet->topUp();
$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(0, $payments[0]->currency_amount);
$this->assertSame(2010, $payments[1]->amount);
$this->assertSame(2010, $payments[1]->currency_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(Payment::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\Mail\PaymentJob::class, 1);
Bus::assertDispatched(\App\Jobs\Mail\PaymentJob::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 = $wallet->topUp();
$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 = $wallet->topUp();
$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 = $wallet->topUp();
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\Mail\PaymentMandateDisabledJob::class, 1);
Bus::assertDispatched(\App\Jobs\Mail\PaymentMandateDisabledJob::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 = $wallet->topUp();
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\Mail\PaymentMandateDisabledJob::class, 1);
// Test webhook for recurring payments
$wallet->transactions()->delete();
Bus::fake();
$payment->refresh();
$payment->status = Payment::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.
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response, 200, []),
]);
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(Payment::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\Mail\PaymentJob::class, 1);
Bus::assertDispatched(\App\Jobs\Mail\PaymentJob::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 = Payment::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",
];
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response, 200, []),
]);
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertSame(Payment::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\Mail\PaymentJob::class, 1);
Bus::assertDispatched(\App\Jobs\Mail\PaymentJob::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
}
/**
* Test refund/chargeback handling by the webhook
*
* @group mollie
*/
public function testRefundAndChargeback(): void
{
Bus::fake();
$user = $this->getTestUser('euro@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->currency = 'EUR';
$wallet->save();
$wallet->transactions()->delete();
// Create a paid payment
$payment = Payment::create([
'id' => 'tr_123456',
'status' => Payment::STATUS_PAID,
'amount' => 123,
'credit_amount' => 123,
'currency_amount' => 123,
'currency' => 'EUR',
'type' => Payment::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" => "EUR",
"value" => "1.01",
],
]
]
]
];
// We'll trigger the webhook with payment id and use mocking for
// requests to the Mollie payments API.
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response1, 200, []),
self::API_URL . "/payments/{$payment->id}/refunds" => Http::response($mollie_response2, 200, []),
]);
$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(-101, $payments[0]->currency_amount);
$this->assertSame(Payment::STATUS_PAID, $payments[0]->status);
$this->assertSame(Payment::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" => "EUR",
"value" => "0.15",
],
]
]
]
];
// We'll trigger the webhook with payment id and use mocking for
// requests to the Mollie payments API.
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response1, 200, []),
self::API_URL . "/payments/{$payment->id}/chargebacks" => Http::response($mollie_response2, 200, []),
]);
$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(Payment::STATUS_PAID, $payments[0]->status);
$this->assertSame(Payment::TYPE_CHARGEBACK, $payments[0]->type);
$this->assertSame("mollie", $payments[0]->provider);
$this->assertSame('', $payments[0]->description);
Bus::assertNotDispatched(\App\Jobs\Mail\PaymentJob::class);
}
/**
* Test listing a pending payment
*
* @group mollie
*/
public function testListingPayments(): void
{
Bus::fake();
$user = $this->getTestUser('euro@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->currency = 'EUR';
$wallet->save();
//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']);
// Successful payment
$post = ['amount' => '12.34', 'currency' => 'EUR', '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(Payment::STATUS_OPEN, $json['list'][0]['status']);
$this->assertSame('EUR', $json['list'][0]['currency']);
$this->assertSame(Payment::TYPE_ONEOFF, $json['list'][0]['type']);
$this->assertSame(1234, $json['list'][0]['amount']);
$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 = Payment::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('euro@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->currency = 'EUR';
$wallet->save();
$response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . Payment::TYPE_ONEOFF);
$response->assertStatus(200);
$json = $response->json();
$hasCoinbase = !empty(\config('services.coinbase.key'));
$this->assertCount(3 + intval($hasCoinbase), $json);
$this->assertSame('creditcard', $json[0]['id']);
$this->assertSame('paypal', $json[1]['id']);
$this->assertSame('banktransfer', $json[2]['id']);
$this->assertSame('EUR', $json[0]['currency']);
$this->assertSame('EUR', $json[1]['currency']);
$this->assertSame('EUR', $json[2]['currency']);
$this->assertSame(1, $json[0]['exchangeRate']);
$this->assertSame(1, $json[1]['exchangeRate']);
$this->assertSame(1, $json[2]['exchangeRate']);
if ($hasCoinbase) {
$this->assertSame('bitcoin', $json[3]['id']);
$this->assertSame('BTC', $json[3]['currency']);
}
$response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . Payment::TYPE_RECURRING);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
$this->assertSame('creditcard', $json[0]['id']);
$this->assertSame('EUR', $json[0]['currency']);
}
}
diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php
index 6913e8b0..56028b8b 100644
--- a/src/tests/Feature/Controller/PaymentsMollieTest.php
+++ b/src/tests/Feature/Controller/PaymentsMollieTest.php
@@ -1,1166 +1,1166 @@
<?php
namespace Tests\Feature\Controller;
use App\Payment;
use App\Plan;
use App\Providers\PaymentProvider;
use App\Transaction;
use App\Wallet;
use App\WalletSetting;
use App\VatRate;
use App\Utils;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
use Tests\BrowserAddonTrait;
class PaymentsMollieTest extends TestCase
{
use BrowserAddonTrait;
protected const API_URL = 'https://api.mollie.com/v2';
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
if (!\config('services.mollie.key')) {
$this->markTestSkipped('No MOLLIE_KEY');
}
// All tests in this file use Mollie
\config(['services.payment_provider' => 'mollie']);
\config(['app.vat.mode' => 0]);
Utils::setTestExchangeRates(['EUR' => '0.90503424978382']);
$this->deleteTestUser('payment-test@' . \config('app.domain'));
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Payment::query()->delete();
VatRate::query()->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();
Plan::withEnvTenantContext()->where('title', 'individual')->update(['mode' => 'email', 'months' => 1]);
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
if (\config('services.mollie.key')) {
$this->deleteTestUser('payment-test@' . \config('app.domain'));
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Payment::query()->delete();
VatRate::query()->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();
Plan::withEnvTenantContext()->where('title', 'individual')->update(['mode' => 'email', 'months' => 1]);
Utils::setTestExchangeRates([]);
}
parent::tearDown();
}
/**
* Test creating/updating/deleting an outo-payment mandate
*
* @group mollie
* @group slow
*/
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->post("api/v4/payments/mandate/reset", []);
$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 = $wallet->money(Payment::MIN_AMOUNT);
$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' => Payment::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)
$json = $this->createMollieMandate(
$wallet->fresh(),
['amount' => 20.10, 'balance' => 0, 'methodId' => PaymentProvider::METHOD_CREDITCARD]
);
$mandate_id = $json['mandateId'];
// 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($user->tenant->title . " Auto-Payment Setup", $payment->description);
$this->assertSame(Payment::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->assertTrue(in_array($json['method'], ['Mastercard (**** **** **** 9399)', 'Credit Card']));
$this->assertSame(false, $json['isPending']);
$this->assertSame(true, $json['isValid']);
$this->assertSame(false, $json['isDisabled']);
$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->assertTrue(in_array($json['method'], ['Mastercard (**** **** **** 9399)', 'Credit Card']));
$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)
$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);
+ Bus::assertDispatchedTimes(\App\Jobs\Wallet\ChargeJob::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)
$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) {
+ Bus::assertDispatchedTimes(\App\Jobs\Wallet\ChargeJob::class, 1);
+ Bus::assertDispatched(\App\Jobs\Wallet\ChargeJob::class, function ($job) use ($wallet) {
$job_wallet_id = $this->getObjectProperty($job, 'walletId');
return $job_wallet_id === $wallet->id;
});
// Test mandate reset
$wallet->payments()->delete();
$response = $this->actingAs($user)->post("api/v4/payments/mandate/reset", []);
$response->assertStatus(200);
$payment = $wallet->payments()->first();
$this->assertSame(0, $payment->amount);
$this->assertSame($user->tenant->title . " Auto-Payment Setup", $payment->description);
$this->assertSame(Payment::TYPE_MANDATE, $payment->type);
// 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 the mandate does not exist (is not valid) anymore
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isValid']);
$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"
]
]
];
$mollieId = $wallet->getSetting('mollie_id');
Http::fakeClear()->fake([
self::API_URL . "/customers/{$mollieId}/mandates/123" => Http::response($mollie_response, 410, []),
]);
$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 fetching an outo-payment mandate parameters
*
* @group mollie
*/
public function testMandateParams(): void
{
$plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
$user = $this->getTestUser('payment-test@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame((int) ceil(Payment::MIN_AMOUNT / 100), $json['amount']);
$this->assertSame($json['amount'], $json['minAmount']);
$this->assertSame(0, $json['balance']);
$this->assertFalse($json['isValid']);
$this->assertFalse($json['isDisabled']);
$plan->months = 12;
$plan->save();
$user->setSetting('plan_id', $plan->id);
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(round($plan->cost() / 100, 2), $json['minAmount']);
$this->assertEquals($json['minAmount'], $json['amount']);
// TODO: Test more cases
// TODO: Test user unrestricting if mandate is valid
}
/**
* 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);
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Invalid amount
$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 = $wallet->money(Payment::MIN_AMOUNT);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// 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->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']);
$payments = Payment::where('wallet_id', $wallet->id)->get();
$this->assertCount(1, $payments);
$payment = $payments[0];
$this->assertSame(1234, $payment->amount);
$this->assertSame(1234, $payment->currency_amount);
$this->assertSame('CHF', $payment->currency);
$this->assertSame($user->tenant->title . ' 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.
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response, 200, []),
]);
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(Payment::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\Mail\PaymentJob::class, 0);
// Verify "paid -> open -> paid" scenario, assert that balance didn't change
$mollie_response['status'] = 'open';
unset($mollie_response['paidAt']);
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response, 200, []),
]);
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
$mollie_response['status'] = 'paid';
$mollie_response['paidAt'] = date('c');
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response, 200, []),
]);
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Test for payment failure
Bus::fake();
$payment->refresh();
$payment->status = Payment::STATUS_OPEN;
$payment->save();
$mollie_response['status'] = 'failed';
$mollie_response['failedAt'] = date('c');
unset($mollie_response['paidAt']);
// 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.
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response, 200, []),
]);
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(Payment::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\Mail\PaymentJob::class, 0);
}
/**
* Test creating a payment and receiving a status via webhook using a foreign currency
*
* @group mollie
*/
public function testStoreAndWebhookForeignCurrency(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Successful payment in EUR
$post = ['amount' => '12.34', 'currency' => 'EUR', 'methodId' => 'banktransfer'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
$payment = $wallet->payments()
->where('currency', 'EUR')->get()->last();
$this->assertSame(1234, $payment->amount);
$this->assertSame(1117, $payment->currency_amount);
$this->assertSame('EUR', $payment->currency);
$this->assertEquals(0, $wallet->balance);
$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",
];
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response, 200, []),
]);
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
}
/**
* Test automatic payment charges
*
* @group mollie
* @group slow
*/
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->createMollieMandate($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
$this->assertTrue($wallet->topUp());
// 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(0, $payments[0]->currency_amount);
$this->assertSame(2010, $payments[1]->amount);
$this->assertSame(2010, $payments[1]->currency_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(Payment::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\Mail\PaymentJob::class, 1);
Bus::assertDispatched(\App\Jobs\Mail\PaymentJob::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 = $wallet->topUp();
$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 = $wallet->topUp();
$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 = $wallet->topUp();
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\Mail\PaymentMandateDisabledJob::class, 1);
Bus::assertDispatched(\App\Jobs\Mail\PaymentMandateDisabledJob::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 = $wallet->topUp();
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\Mail\PaymentMandateDisabledJob::class, 1);
// Test webhook for recurring payments
$wallet->transactions()->delete();
Bus::fake();
$payment->refresh();
$payment->status = Payment::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.
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response, 200, []),
]);
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(Payment::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\Mail\PaymentJob::class, 1);
Bus::assertDispatched(\App\Jobs\Mail\PaymentJob::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 = Payment::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",
];
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response, 200, []),
]);
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertSame(Payment::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\Mail\PaymentJob::class, 1);
Bus::assertDispatched(\App\Jobs\Mail\PaymentJob::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
}
/**
* Test payment/top-up with VAT_MODE=1
*
* @group mollie
* @group slow
*/
public function testPaymentsWithVatModeOne(): void
{
\config(['app.vat.mode' => 1]);
$user = $this->getTestUser('payment-test@' . \config('app.domain'));
$user->setSetting('country', 'US');
$wallet = $user->wallets()->first();
$vatRate = VatRate::create([
'country' => 'US',
'rate' => 5.0,
'start' => now()->subDay(),
]);
// Payment
$post = ['amount' => '10', 'currency' => 'CHF', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
// Check that the payments table contains a new record with proper amount(s)
$payment = $wallet->payments()->first();
$this->assertSame(1000 + intval(round(1000 * $vatRate->rate / 100)), $payment->amount);
$this->assertSame(1000, $payment->credit_amount);
$this->assertSame($payment->amount, $payment->currency_amount);
$this->assertSame('CHF', $payment->currency);
$this->assertSame($vatRate->id, $payment->vat_rate_id);
$this->assertSame('open', $payment->status);
$wallet->payments()->delete();
$wallet->balance = -1000;
$wallet->save();
// Top-up (mandate creation)
// Create a valid mandate first (expect an extra payment)
$this->createMollieMandate($wallet, ['amount' => 20.10, 'balance' => 0]);
// Check that the payments table contains a new record with proper amount(s)
$payment = $wallet->payments()->first();
$this->assertSame(2010 + intval(round(2010 * $vatRate->rate / 100)), $payment->amount);
$this->assertSame(2010, $payment->credit_amount);
$this->assertSame($payment->amount, $payment->currency_amount);
$this->assertSame($vatRate->id, $payment->vat_rate_id);
$wallet->payments()->delete();
$wallet->balance = -1000;
$wallet->save();
// Top-up (recurring payment)
// Expect a recurring payment as we have a valid mandate at this point
// and the balance is below the threshold
$this->assertTrue($wallet->topUp());
// Check that the payments table contains a new record with proper amount(s)
$payment = $wallet->payments()->first();
$this->assertSame(2010 + intval(round(2010 * $vatRate->rate / 100)), $payment->amount);
$this->assertSame(2010, $payment->credit_amount);
$this->assertSame($payment->amount, $payment->currency_amount);
$this->assertSame($vatRate->id, $payment->vat_rate_id);
}
/**
* 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();
// Create a paid payment
$payment = Payment::create([
'id' => 'tr_123456',
'status' => Payment::STATUS_PAID,
'amount' => 123,
'credit_amount' => 123,
'currency_amount' => 123,
'currency' => 'CHF',
'type' => Payment::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.
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response1, 200, []),
self::API_URL . "/payments/{$payment->id}/refunds" => Http::response($mollie_response2, 200, []),
]);
$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(-101, $payments[0]->currency_amount);
$this->assertSame(Payment::STATUS_PAID, $payments[0]->status);
$this->assertSame(Payment::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.
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response1, 200, []),
self::API_URL . "/payments/{$payment->id}/chargebacks" => Http::response($mollie_response2, 200, []),
]);
$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(Payment::STATUS_PAID, $payments[0]->status);
$this->assertSame(Payment::TYPE_CHARGEBACK, $payments[0]->type);
$this->assertSame("mollie", $payments[0]->provider);
$this->assertSame('', $payments[0]->description);
Bus::assertNotDispatched(\App\Jobs\Mail\PaymentJob::class);
}
/**
* Test refund/chargeback handling by the webhook in a foreign currency
*
* @group mollie
*/
public function testRefundAndChargebackForeignCurrency(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->transactions()->delete();
// Create a paid payment
$payment = Payment::create([
'id' => 'tr_123456',
'status' => Payment::STATUS_PAID,
'amount' => 1234,
'credit_amount' => 1234,
'currency_amount' => 1117,
'currency' => 'EUR',
'type' => Payment::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" => "EUR",
"value" => "1.01",
],
]
]
]
];
// We'll trigger the webhook with payment id and use mocking for
// requests to the Mollie payments API.
Http::fakeClear()->fake([
self::API_URL . "/payments/{$payment->id}" => Http::response($mollie_response1, 200, []),
self::API_URL . "/payments/{$payment->id}/refunds" => Http::response($mollie_response2, 200, []),
]);
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertTrue($wallet->balance <= -100);
$this->assertTrue($wallet->balance >= -114);
$payments = $wallet->payments()->where('id', 're_123456')->get();
$this->assertCount(1, $payments);
$this->assertTrue($payments[0]->amount <= -100);
$this->assertTrue($payments[0]->amount >= -114);
$this->assertSame(-101, $payments[0]->currency_amount);
$this->assertSame('EUR', $payments[0]->currency);
}
/**
* 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(Payment::STATUS_OPEN, $json['list'][0]['status']);
$this->assertSame('CHF', $json['list'][0]['currency']);
$this->assertSame(Payment::TYPE_ONEOFF, $json['list'][0]['type']);
$this->assertSame(1234, $json['list'][0]['amount']);
$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 = Payment::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=' . Payment::TYPE_ONEOFF);
$response->assertStatus(200);
$json = $response->json();
$hasCoinbase = !empty(\config('services.coinbase.key'));
$this->assertCount(3 + intval($hasCoinbase), $json);
$this->assertSame('creditcard', $json[0]['id']);
$this->assertSame('paypal', $json[1]['id']);
$this->assertSame('banktransfer', $json[2]['id']);
$this->assertSame('CHF', $json[0]['currency']);
$this->assertSame('CHF', $json[1]['currency']);
$this->assertSame('EUR', $json[2]['currency']);
if ($hasCoinbase) {
$this->assertSame('bitcoin', $json[3]['id']);
$this->assertSame('BTC', $json[3]['currency']);
}
$response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . Payment::TYPE_RECURRING);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
$this->assertSame('creditcard', $json[0]['id']);
$this->assertSame('CHF', $json[0]['currency']);
}
}
diff --git a/src/tests/Feature/Controller/PaymentsStripeTest.php b/src/tests/Feature/Controller/PaymentsStripeTest.php
index a07f1c00..acd1b4b0 100644
--- a/src/tests/Feature/Controller/PaymentsStripeTest.php
+++ b/src/tests/Feature/Controller/PaymentsStripeTest.php
@@ -1,890 +1,890 @@
<?php
namespace Tests\Feature\Controller;
use App\Payment;
use App\Providers\PaymentProvider;
use App\Transaction;
use App\Wallet;
use App\WalletSetting;
use App\VatRate;
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();
if (!\config('services.stripe.key')) {
$this->markTestSkipped('No STRIPE_KEY');
}
// All tests in this file use Stripe
\config(['services.payment_provider' => 'stripe']);
\config(['app.vat.mode' => 0]);
$this->deleteTestUser('payment-test@' . \config('app.domain'));
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
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();
Payment::query()->delete();
VatRate::query()->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
if (\config('services.stripe.key')) {
$this->deleteTestUser('payment-test@' . \config('app.domain'));
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
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();
Payment::query()->delete();
VatRate::query()->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->post("api/v4/payments/mandate/reset", []);
$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 = $wallet->money(Payment::MIN_AMOUNT);
$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' => Payment::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, 'methodId' => PaymentProvider::METHOD_CREDITCARD];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertMatchesRegularExpression('|^cs_test_|', $json['id']);
// Assert the proper payment amount has been used
// Stripe in 'setup' mode does not allow to set the amount
$payment = $wallet->payments()->first();
$this->assertSame(0, $payment->amount);
$this->assertSame($user->tenant->title . " Auto-Payment Setup", $payment->description);
$this->assertSame(Payment::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) {
+ Bus::assertDispatchedTimes(\App\Jobs\Wallet\ChargeJob::class, 1);
+ Bus::assertDispatched(\App\Jobs\Wallet\ChargeJob::class, function ($job) use ($wallet) {
$job_wallet_id = $this->getObjectProperty($job, 'walletId');
return $job_wallet_id === $wallet->id;
});
$this->unmockStripe();
// Test mandate reset
$wallet->payments()->delete();
$response = $this->actingAs($user)->post("api/v4/payments/mandate/reset", []);
$response->assertStatus(200);
$payment = $wallet->payments()->first();
$this->assertSame(0, $payment->amount);
$this->assertSame($user->tenant->title . " Auto-Payment Setup", $payment->description);
$this->assertSame(Payment::TYPE_MANDATE, $payment->type);
// Delete mandate
$wallet->setSetting('mandate_disabled', 1);
$client = $this->mockStripe();
$client->addResponse($setupIntent);
$client->addResponse($paymentMethod);
$client->addResponse($paymentMethod);
$response = $this->actingAs($user)->delete("api/v4/payments/mandate");
$response->assertStatus(200);
$this->assertNull($wallet->getSetting('mandate_disabled'));
$this->assertNull($wallet->getSetting('stripe_mandate_id'));
$this->unmockStripe();
}
/**
* 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');
$wallet = $user->wallets()->first();
$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 = $wallet->money(Payment::MIN_AMOUNT);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// 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->assertMatchesRegularExpression('|^cs_test_|', $json['id']);
$payments = Payment::where('wallet_id', $wallet->id)->get();
$this->assertCount(1, $payments);
$payment = $payments[0];
$this->assertSame(1234, $payment->amount);
$this->assertSame($user->tenant->title . ' 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(Payment::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\Mail\PaymentJob::class, 0);
// Test that balance didn't change if the same event is posted
$response = $this->webhookRequest($post);
$response->assertStatus(200);
$this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Test for payment failure ('failed' status)
$payment->refresh();
$payment->status = Payment::STATUS_OPEN;
$payment->save();
$post['type'] = "payment_intent.payment_failed";
$post['data']['object']['status'] = 'failed';
$response = $this->webhookRequest($post);
$response->assertStatus(200);
$this->assertSame(Payment::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\Mail\PaymentJob::class, 0);
// Test for payment failure ('canceled' status)
$payment->refresh();
$payment->status = Payment::STATUS_OPEN;
$payment->save();
$post['type'] = "payment_intent.canceled";
$post['data']['object']['status'] = 'canceled';
$response = $this->webhookRequest($post);
$response->assertStatus(200);
$this->assertSame(Payment::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\Mail\PaymentJob::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(Payment::STATUS_OPEN, $payment->status);
$this->assertSame(Payment::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(Payment::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) {
+ // Expect a wallet charge job if the balance is negative
+ Bus::assertDispatchedTimes(\App\Jobs\Wallet\ChargeJob::class, 1);
+ Bus::assertDispatched(\App\Jobs\Wallet\ChargeJob::class, function ($job) use ($wallet) {
$job_wallet_id = TestCase::getObjectProperty($job, 'walletId');
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" => $user->tenant->title . " 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 = $wallet->topUp();
$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($user->tenant->title . " Recurring Payment", $payment->description);
$this->assertSame("pi_XX", $payment->id);
// Expect no payment if the mandate is disabled
$wallet->setSetting('mandate_disabled', 1);
$result = $wallet->topUp();
$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 = $wallet->topUp();
$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 = $wallet->topUp();
$this->assertFalse($result);
$this->assertCount(1, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\Mail\PaymentMandateDisabledJob::class, 1);
Bus::assertDispatched(\App\Jobs\Mail\PaymentMandateDisabledJob::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 = $wallet->topUp();
$this->assertFalse($result);
$this->assertCount(1, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\Mail\PaymentMandateDisabledJob::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(Payment::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\Mail\PaymentJob::class, 1);
Bus::assertDispatched(\App\Jobs\Mail\PaymentJob::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 = Payment::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(Payment::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\Mail\PaymentJob::class, 1);
Bus::assertDispatched(\App\Jobs\Mail\PaymentJob::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 = Payment::STATUS_OPEN;
$payment->save();
$post['type'] = "payment_intent.canceled";
$post['data']['object']['status'] = 'canceled';
$response = $this->webhookRequest($post);
$response->assertStatus(200);
$this->assertSame(Payment::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\Mail\PaymentJob::class, 0);
}
/**
* Test payment/top-up with VAT_MODE=1
*
* @group stripe
*/
public function testPaymentsWithVatModeOne(): void
{
\config(['app.vat.mode' => 1]);
$user = $this->getTestUser('payment-test@' . \config('app.domain'));
$user->setSetting('country', 'US');
$wallet = $user->wallets()->first();
$vatRate = VatRate::create([
'country' => 'US',
'rate' => 5.0,
'start' => now()->subDay(),
]);
// Payment
$post = ['amount' => '10', 'currency' => 'CHF', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
// Check that the payments table contains a new record with proper amount(s)
$payment = $wallet->payments()->first();
$this->assertSame(1000 + intval(round(1000 * $vatRate->rate / 100)), $payment->amount);
$this->assertSame(1000, $payment->credit_amount);
$this->assertSame($payment->amount, $payment->currency_amount);
$this->assertSame('CHF', $payment->currency);
$this->assertSame($vatRate->id, $payment->vat_rate_id);
$this->assertSame('open', $payment->status);
$wallet->payments()->delete();
$wallet->balance = -1000;
$wallet->save();
// Top-up (mandate creation)
// Create a valid mandate first (expect an extra payment)
$post = ['amount' => 20.10, 'balance' => 0, 'methodId' => PaymentProvider::METHOD_CREDITCARD];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(200);
// Check that the payments table contains a new record with proper amount(s)
// Stripe mandates always use amount=0
$payment = $wallet->payments()->first();
$this->assertSame(0, $payment->amount);
$this->assertSame(0, $payment->credit_amount);
$this->assertSame(0, $payment->currency_amount);
$this->assertSame(null, $payment->vat_rate_id);
$wallet->payments()->delete();
$wallet->balance = -1000;
$wallet->save();
// Top-up (recurring payment)
// Expect a recurring payment as we have a valid mandate at this point
// and the balance is below the threshold
$wallet->setSettings(['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 + intval(round(2010 * $vatRate->rate / 100)),
"currency" => "chf",
"description" => "Recurring Payment"
]);
$client = $this->mockStripe();
$client->addResponse($setupIntent);
$client->addResponse($paymentMethod);
$client->addResponse($setupIntent);
$client->addResponse($paymentIntent);
$result = $wallet->topUp();
$this->assertTrue($result);
// Check that the payments table contains a new record with proper amount(s)
$payment = $wallet->payments()->first();
$this->assertSame(2010 + intval(round(2010 * $vatRate->rate / 100)), $payment->amount);
$this->assertSame(2010, $payment->credit_amount);
$this->assertSame($payment->amount, $payment->currency_amount);
$this->assertSame($vatRate->id, $payment->vat_rate_id);
}
/**
* 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=' . Payment::TYPE_ONEOFF);
$response->assertStatus(200);
$json = $response->json();
$hasCoinbase = !empty(\config('services.coinbase.key'));
$this->assertCount(2 + intval($hasCoinbase), $json);
$this->assertSame('creditcard', $json[0]['id']);
$this->assertSame('paypal', $json[1]['id']);
if ($hasCoinbase) {
$this->assertSame('bitcoin', $json[2]['id']);
}
$response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . Payment::TYPE_RECURRING);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
$this->assertSame('creditcard', $json[0]['id']);
}
/**
* 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);
}
}
diff --git a/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php b/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php
index 5964fd56..146e36cc 100644
--- a/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php
+++ b/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php
@@ -1,236 +1,236 @@
<?php
namespace Tests\Feature\Controller\Reseller;
use App\Http\Controllers\API\V4\Reseller\PaymentsController;
use App\Payment;
use App\Transaction;
use App\Wallet;
use App\WalletSetting;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
use Tests\BrowserAddonTrait;
class PaymentsMollieTest extends TestCase
{
use BrowserAddonTrait;
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
if (!\config('services.mollie.key')) {
$this->markTestSkipped('No MOLLIE_KEY');
}
// All tests in this file use Mollie
\config(['services.payment_provider' => 'mollie']);
$reseller = $this->getTestUser('reseller@' . \config('app.domain'));
$wallet = $reseller->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)->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
if (\config('services.mollie.key')) {
$reseller = $this->getTestUser('reseller@' . \config('app.domain'));
$wallet = $reseller->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)->delete();
}
parent::tearDown();
}
/**
* Test creating/updating/deleting an outo-payment mandate
*
* @group mollie
* @group slow
*/
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);
$reseller = $this->getTestUser('reseller@' . \config('app.domain'));
$wallet = $reseller->wallets()->first();
$wallet->balance = -10;
$wallet->save();
// Test creating a mandate (valid input)
$json = $this->createMollieMandate($wallet, ['amount' => 20.10, 'balance' => 0]);
$mandate_id = $json['mandateId'];
// 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($reseller->tenant->title . " Auto-Payment Setup", $payment->description);
$this->assertSame(Payment::TYPE_MANDATE, $payment->type);
// Test fetching the mandate information
$response = $this->actingAs($reseller)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertTrue(in_array($json['method'], ['Mastercard (**** **** **** 9399)', 'Credit Card']));
$this->assertSame(false, $json['isPending']);
$this->assertSame(true, $json['isValid']);
$this->assertSame(false, $json['isDisabled']);
$wallet = $reseller->wallets()->first();
$wallet->setSetting('mandate_disabled', 1);
$response = $this->actingAs($reseller)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertTrue(in_array($json['method'], ['Mastercard (**** **** **** 9399)', 'Credit Card']));
$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 a mandate (valid input)
$post = ['amount' => 30.10, 'balance' => 10];
$response = $this->actingAs($reseller)->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);
+ Bus::assertDispatchedTimes(\App\Jobs\Wallet\ChargeJob::class, 0);
// Delete mandate
$response = $this->actingAs($reseller)->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']);
}
/**
* Test creating a payment
*
* @group mollie
*/
public function testStore(): void
{
Bus::fake();
// Unauth access not allowed
$response = $this->post("api/v4/payments", []);
$response->assertStatus(401);
$reseller = $this->getTestUser('reseller@' . \config('app.domain'));
// Successful payment
$post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard'];
$response = $this->actingAs($reseller)->post("api/v4/payments", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']);
}
/**
* Test listing a pending payment
*
* @group mollie
*/
public function testListingPayments(): void
{
Bus::fake();
$reseller = $this->getTestUser('reseller@' . \config('app.domain'));
// Empty response
$response = $this->actingAs($reseller)->get("api/v4/payments/pending");
$response->assertStatus(200);
$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($reseller)->get("api/v4/payments/has-pending");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasPending']);
}
/**
* Test listing payment methods
*
* @group mollie
*/
public function testListingPaymentMethods(): void
{
Bus::fake();
$reseller = $this->getTestUser('reseller@' . \config('app.domain'));
$response = $this->actingAs($reseller)->get('api/v4/payments/methods?type=' . Payment::TYPE_ONEOFF);
$response->assertStatus(200);
$json = $response->json();
$hasCoinbase = !empty(\config('services.coinbase.key'));
$this->assertCount(3 + intval($hasCoinbase), $json);
$this->assertSame('creditcard', $json[0]['id']);
$this->assertSame('paypal', $json[1]['id']);
$this->assertSame('banktransfer', $json[2]['id']);
$this->assertSame('bitcoin', $json[3]['id']);
}
}
diff --git a/src/tests/Feature/Jobs/WalletCheckTest.php b/src/tests/Feature/Jobs/Wallet/CheckTest.php
similarity index 89%
rename from src/tests/Feature/Jobs/WalletCheckTest.php
rename to src/tests/Feature/Jobs/Wallet/CheckTest.php
index 3fa11f57..6a625668 100644
--- a/src/tests/Feature/Jobs/WalletCheckTest.php
+++ b/src/tests/Feature/Jobs/Wallet/CheckTest.php
@@ -1,319 +1,319 @@
<?php
-namespace Tests\Feature\Jobs;
+namespace Tests\Feature\Jobs\Wallet;
-use App\Jobs\WalletCheck;
+use App\Jobs\Wallet\CheckJob;
use App\User;
use App\Wallet;
use Carbon\Carbon;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
-class WalletCheckTest extends TestCase
+class CheckTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
Carbon::setTestNow(Carbon::createFromDate(2022, 02, 02));
$this->deleteTestUser('wallet-check@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('wallet-check@kolabnow.com');
parent::tearDown();
}
/**
* Test job's queue assignment on dispatch
*/
public function testDispatchQueue(): void
{
// Test that the job is dispatched to the proper queue
Queue::fake();
$user = $this->getTestUser('jack@kolab.org');
- WalletCheck::dispatch($user->wallets()->first()->id);
- Queue::assertPushedOn(\App\Enums\Queue::Background->value, WalletCheck::class);
+ CheckJob::dispatch($user->wallets()->first()->id);
+ Queue::assertPushedOn(\App\Enums\Queue::Background->value, CheckJob::class);
}
/**
* Test job handle, initial negative-balance notification
*/
public function testHandleInitial(): void
{
Mail::fake();
$user = $this->prepareTestUser($wallet);
$now = Carbon::now();
$wallet->balance = 0;
$wallet->save();
- $job = new WalletCheck($wallet->id);
+ $job = new CheckJob($wallet->id);
$job->handle();
// Ensure the job ends up on the correct queue
$this->assertSame(\App\Enums\Queue::Background->value, $job->queue);
Mail::assertNothingSent();
// Balance is negative now
$wallet->balance = -100;
$wallet->save();
- $job = new WalletCheck($wallet->id);
+ $job = new CheckJob($wallet->id);
$job->handle();
Mail::assertNothingSent();
// Balance turned negative 2 hours ago, expect mail sent
$wallet->setSetting('balance_negative_since', $now->subHours(2)->toDateTimeString());
$wallet->setSetting('balance_warning_initial', null);
- $job = new WalletCheck($wallet->id);
+ $job = new CheckJob($wallet->id);
$job->handle();
// Assert the mail was sent to the user's email, but not to his external email
Mail::assertSent(\App\Mail\NegativeBalance::class, 1);
Mail::assertSent(\App\Mail\NegativeBalance::class, function ($mail) use ($user) {
return $mail->hasTo($user->email) && !$mail->hasCc('external@test.com');
});
// Run the job again to make sure the notification is not sent again
Mail::fake();
- $job = new WalletCheck($wallet->id);
+ $job = new CheckJob($wallet->id);
$job->handle();
Mail::assertNothingSent();
// Test the migration scenario where a negative wallet has no balance_negative_since set yet
Mail::fake();
$wallet->setSetting('balance_negative_since', null);
$wallet->setSetting('balance_warning_initial', null);
- $job = new WalletCheck($wallet->id);
+ $job = new CheckJob($wallet->id);
$job->handle();
// Assert the mail was sent to the user's email, but not to his external email
Mail::assertSent(\App\Mail\NegativeBalance::class, 1);
Mail::assertSent(\App\Mail\NegativeBalance::class, function ($mail) use ($user) {
return $mail->hasTo($user->email) && !$mail->hasCc('external@test.com');
});
$wallet->refresh();
$today_regexp = '/' . Carbon::now()->toDateString() . ' [0-9]{2}:[0-9]{2}:[0-9]{2}/';
$this->assertMatchesRegularExpression($today_regexp, $wallet->getSetting('balance_negative_since'));
$this->assertMatchesRegularExpression($today_regexp, $wallet->getSetting('balance_warning_initial'));
// Test suspended user - no mail sent
Mail::fake();
$wallet->owner->suspend();
$wallet->setSetting('balance_warning_initial', null);
- $job = new WalletCheck($wallet->id);
+ $job = new CheckJob($wallet->id);
$job->handle();
Mail::assertNothingSent();
}
/**
* Test job handle, wallet charge and top-up
*/
public function testHandleInitialCharge(): void
{
Mail::fake();
Queue::fake();
$user = $this->prepareTestUser($wallet);
$wallet->balance = 0;
$wallet->save();
$this->backdateEntitlements($wallet->entitlements, Carbon::now()->subWeeks(5));
- $job = new WalletCheck($wallet->id);
+ $job = new CheckJob($wallet->id);
$job->handle();
$wallet->refresh();
$this->assertTrue($wallet->balance < 0); // @phpstan-ignore-line
// TODO: Wallet::topUp() to make sure it was called
$this->markTestIncomplete();
}
/**
* Test job handle, reminder notification
*/
public function testHandleReminder(): void
{
Mail::fake();
$user = $this->prepareTestUser($wallet);
$now = Carbon::now();
// Balance turned negative 7+1 days ago, expect mail sent
$wallet->setSetting('balance_negative_since', $now->subDays(7 + 1)->toDateTimeString());
- $job = new WalletCheck($wallet->id);
+ $job = new CheckJob($wallet->id);
$job->handle();
// Assert the mail was sent to the user's email and to his external email
Mail::assertSent(\App\Mail\NegativeBalanceReminderDegrade::class, 1);
Mail::assertSent(\App\Mail\NegativeBalanceReminderDegrade::class, function ($mail) use ($user) {
return $mail->hasTo($user->email) && $mail->hasCc('external@test.com');
});
// Run the job again to make sure the notification is not sent again
Mail::fake();
- $job = new WalletCheck($wallet->id);
+ $job = new CheckJob($wallet->id);
$job->handle();
Mail::assertNothingSent();
// Test suspended user - no mail sent
Mail::fake();
$wallet->owner->suspend();
$wallet->setSetting('balance_warning_reminder', null);
- $job = new WalletCheck($wallet->id);
+ $job = new CheckJob($wallet->id);
$job->handle();
Mail::assertNothingSent();
}
/**
* Test job handle, account degrade
*
* @depends testHandleReminder
*/
public function testHandleDegrade(): void
{
Mail::fake();
$user = $this->prepareTestUser($wallet);
$now = Carbon::now();
$this->assertFalse($user->isDegraded());
// Balance turned negative 7+7+1 days ago, expect mail sent
$days = 7 + 7 + 1;
$wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString());
- $job = new WalletCheck($wallet->id);
+ $job = new CheckJob($wallet->id);
$job->handle();
// Assert the mail was sent to the user's email, and his external email
Mail::assertSent(\App\Mail\NegativeBalanceDegraded::class, 1);
Mail::assertSent(\App\Mail\NegativeBalanceDegraded::class, function ($mail) use ($user) {
return $mail->hasTo($user->email) && $mail->hasCc('external@test.com');
});
// Check that it has been degraded
$this->assertTrue($user->fresh()->isDegraded());
// Test suspended user - no mail sent
Mail::fake();
$wallet->owner->suspend();
$wallet->owner->undegrade();
- $job = new WalletCheck($wallet->id);
+ $job = new CheckJob($wallet->id);
$job->handle();
Mail::assertNothingSent();
}
/**
* Test job handle, periodic reminder to a degraded account
*
* @depends testHandleDegrade
*/
public function testHandleDegradeReminder(): void
{
Mail::fake();
$user = $this->prepareTestUser($wallet);
$user->update(['status' => $user->status | User::STATUS_DEGRADED]);
$now = Carbon::now();
$this->assertTrue($user->isDegraded());
// Test degraded_last_reminder not set
$wallet->setSetting('degraded_last_reminder', null);
- $job = new WalletCheck($wallet->id);
+ $job = new CheckJob($wallet->id);
$res = $job->handle();
Mail::assertNothingSent();
$_last = Wallet::find($wallet->id)->getSetting('degraded_last_reminder');
$this->assertSame(Carbon::now()->toDateTimeString(), $_last);
- $this->assertSame(WalletCheck::THRESHOLD_DEGRADE_REMINDER, $res);
+ $this->assertSame(CheckJob::THRESHOLD_DEGRADE_REMINDER, $res);
// Test degraded_last_reminder set, but 14 days didn't pass yet
$last = $now->copy()->subDays(10);
$wallet->setSetting('degraded_last_reminder', $last->toDateTimeString());
- $job = new WalletCheck($wallet->id);
+ $job = new CheckJob($wallet->id);
$res = $job->handle();
Mail::assertNothingSent();
$_last = $wallet->fresh()->getSetting('degraded_last_reminder');
- $this->assertSame(WalletCheck::THRESHOLD_DEGRADE_REMINDER, $res);
+ $this->assertSame(CheckJob::THRESHOLD_DEGRADE_REMINDER, $res);
$this->assertSame($last->toDateTimeString(), $_last);
// Test degraded_last_reminder set, and 14 days passed
$wallet->setSetting('degraded_last_reminder', $now->copy()->subDays(14)->setSeconds(0));
- $job = new WalletCheck($wallet->id);
+ $job = new CheckJob($wallet->id);
$res = $job->handle();
// Assert the mail was sent to the user's email, and his external email
Mail::assertSent(\App\Mail\DegradedAccountReminder::class, 1);
Mail::assertSent(\App\Mail\DegradedAccountReminder::class, function ($mail) use ($user) {
return $mail->hasTo($user->email) && !$mail->hasCc('external@test.com');
});
$_last = $wallet->fresh()->getSetting('degraded_last_reminder');
$this->assertSame(Carbon::now()->toDateTimeString(), $_last);
- $this->assertSame(WalletCheck::THRESHOLD_DEGRADE_REMINDER, $res);
+ $this->assertSame(CheckJob::THRESHOLD_DEGRADE_REMINDER, $res);
// Test suspended user - no mail sent
Mail::fake();
$wallet->owner->suspend();
$wallet->owner->undegrade();
$wallet->setSetting('degraded_last_reminder', null);
- $job = new WalletCheck($wallet->id);
+ $job = new CheckJob($wallet->id);
$job->handle();
Mail::assertNothingSent();
}
/**
* A helper to prepare a user for tests
*/
private function prepareTestUser(&$wallet)
{
$status = User::STATUS_ACTIVE | User::STATUS_LDAP_READY | User::STATUS_IMAP_READY;
$user = $this->getTestUser('wallet-check@kolabnow.com', ['status' => $status]);
$user->setSetting('external_email', 'external@test.com');
$wallet = $user->wallets()->first();
$package = \App\Package::withObjectTenantContext($user)->where('title', 'kolab')->first();
$user->assignPackage($package);
$wallet->balance = -100;
$wallet->save();
return $user;
}
}
diff --git a/src/tests/Unit/Mail/NegativeBalanceDegradedTest.php b/src/tests/Unit/Mail/NegativeBalanceDegradedTest.php
index 81c89f04..c42ab02e 100644
--- a/src/tests/Unit/Mail/NegativeBalanceDegradedTest.php
+++ b/src/tests/Unit/Mail/NegativeBalanceDegradedTest.php
@@ -1,71 +1,70 @@
<?php
namespace Tests\Unit\Mail;
-use App\Jobs\WalletCheck;
use App\Mail\NegativeBalanceDegraded;
use App\User;
use App\Wallet;
use Tests\TestCase;
class NegativeBalanceDegradedTest extends TestCase
{
/**
* Test email content
*/
public function testBuild(): void
{
$user = $this->getTestUser('ned@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets->first();
$wallet->balance = -100;
$wallet->save();
\config([
'app.support_url' => 'https://kolab.org/support',
]);
$mail = $this->renderMail(new NegativeBalanceDegraded($wallet, $user));
$html = $mail['html'];
$plain = $mail['plain'];
$walletUrl = \App\Utils::serviceUrl('/wallet');
$walletLink = sprintf('<a href="%s">%s</a>', $walletUrl, $walletUrl);
$supportUrl = \config('app.support_url');
$supportLink = sprintf('<a href="%s">%s</a>', $supportUrl, $supportUrl);
$appName = $user->tenant->title;
$this->assertSame("$appName Account Degraded", $mail['subject']);
$this->assertStringStartsWith('<!DOCTYPE html>', $html);
$this->assertTrue(strpos($html, $user->name(true)) > 0);
$this->assertTrue(strpos($html, $walletLink) > 0);
$this->assertTrue(strpos($html, $supportLink) > 0);
$this->assertTrue(strpos($html, "Your {$john->email} account has been degraded") > 0);
$this->assertTrue(strpos($html, "$appName Support") > 0);
$this->assertTrue(strpos($html, "$appName Team") > 0);
$this->assertStringStartsWith('Dear ' . $user->name(true), $plain);
$this->assertTrue(strpos($plain, $walletUrl) > 0);
$this->assertTrue(strpos($plain, $supportUrl) > 0);
$this->assertTrue(strpos($plain, "Your {$john->email} account has been degraded") > 0);
$this->assertTrue(strpos($plain, "$appName Support") > 0);
$this->assertTrue(strpos($plain, "$appName Team") > 0);
}
/**
* Test getSubject() and getUser()
*/
public function testGetSubjectAndUser(): void
{
$user = $this->getTestUser('ned@kolab.org');
$wallet = $user->wallets->first();
$appName = $user->tenant->title;
$mail = new NegativeBalanceDegraded($wallet, $user);
$this->assertSame("$appName Account Degraded", $mail->getSubject());
$this->assertSame($user, $mail->getUser());
}
}
diff --git a/src/tests/Unit/Mail/NegativeBalanceReminderDegradeTest.php b/src/tests/Unit/Mail/NegativeBalanceReminderDegradeTest.php
index 8e7111a0..aacefe38 100644
--- a/src/tests/Unit/Mail/NegativeBalanceReminderDegradeTest.php
+++ b/src/tests/Unit/Mail/NegativeBalanceReminderDegradeTest.php
@@ -1,77 +1,77 @@
<?php
namespace Tests\Unit\Mail;
-use App\Jobs\WalletCheck;
+use App\Jobs\Wallet\CheckJob;
use App\Mail\NegativeBalanceReminderDegrade;
use App\User;
use App\Wallet;
use Tests\TestCase;
class NegativeBalanceReminderDegradeTest extends TestCase
{
/**
* Test email content
*/
public function testBuild(): void
{
$user = $this->getTestUser('ned@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets->first();
$wallet->balance = -100;
$wallet->save();
- $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_DEGRADE);
+ $threshold = CheckJob::threshold($wallet, CheckJob::THRESHOLD_DEGRADE);
\config([
'app.support_url' => 'https://kolab.org/support',
]);
$mail = $this->renderMail(new NegativeBalanceReminderDegrade($wallet, $user));
$html = $mail['html'];
$plain = $mail['plain'];
$walletUrl = \App\Utils::serviceUrl('/wallet');
$walletLink = sprintf('<a href="%s">%s</a>', $walletUrl, $walletUrl);
$supportUrl = \config('app.support_url');
$supportLink = sprintf('<a href="%s">%s</a>', $supportUrl, $supportUrl);
$appName = $user->tenant->title;
$this->assertSame("$appName Payment Reminder", $mail['subject']);
$this->assertStringStartsWith('<!DOCTYPE html>', $html);
$this->assertTrue(strpos($html, $user->name(true)) > 0);
$this->assertTrue(strpos($html, $walletLink) > 0);
$this->assertTrue(strpos($html, $supportLink) > 0);
$this->assertTrue(strpos($html, "you are behind on paying for your {$john->email} account") > 0);
$this->assertTrue(strpos($html, "your account will be degraded") > 0);
$this->assertTrue(strpos($html, $threshold->toDateString()) > 0);
$this->assertTrue(strpos($html, "$appName Support") > 0);
$this->assertTrue(strpos($html, "$appName Team") > 0);
$this->assertStringStartsWith('Dear ' . $user->name(true), $plain);
$this->assertTrue(strpos($plain, $walletUrl) > 0);
$this->assertTrue(strpos($plain, $supportUrl) > 0);
$this->assertTrue(strpos($plain, "you are behind on paying for your {$john->email} account") > 0);
$this->assertTrue(strpos($plain, "your account will be degraded") > 0);
$this->assertTrue(strpos($plain, $threshold->toDateString()) > 0);
$this->assertTrue(strpos($plain, "$appName Support") > 0);
$this->assertTrue(strpos($plain, "$appName Team") > 0);
}
/**
* Test getSubject() and getUser()
*/
public function testGetSubjectAndUser(): void
{
$user = $this->getTestUser('ned@kolab.org');
$wallet = $user->wallets->first();
$appName = $user->tenant->title;
$mail = new NegativeBalanceReminderDegrade($wallet, $user);
$this->assertSame("$appName Payment Reminder", $mail->getSubject());
$this->assertSame($user, $mail->getUser());
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Apr 4, 1:23 AM (1 w, 6 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18821907
Default Alt Text
(197 KB)

Event Timeline