Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117749083
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
197 KB
Referenced Files
None
Subscribers
None
View Options
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
Details
Attached
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)
Attached To
Mode
rK kolab
Attached
Detach File
Event Timeline