diff --git a/src/.env.example b/src/.env.example
--- a/src/.env.example
+++ b/src/.env.example
@@ -6,6 +6,8 @@
APP_PUBLIC_URL=
APP_DOMAIN=kolabnow.com
+SUPPORT_URL=
+
LOG_CHANNEL=stack
DB_CONNECTION=mysql
diff --git a/src/app/Console/Commands/WalletCharge.php b/src/app/Console/Commands/WalletCharge.php
--- a/src/app/Console/Commands/WalletCharge.php
+++ b/src/app/Console/Commands/WalletCharge.php
@@ -2,11 +2,7 @@
namespace App\Console\Commands;
-use App\Domain;
-use App\User;
-use Carbon\Carbon;
use Illuminate\Console\Command;
-use Illuminate\Support\Facades\DB;
class WalletCharge extends Command
{
@@ -44,19 +40,15 @@
$wallets = \App\Wallet::all();
foreach ($wallets as $wallet) {
- $charge = $wallet->expectedCharges();
+ $charge = $wallet->chargeEntitlements();
if ($charge > 0) {
$this->info(
- "charging wallet {$wallet->id} for user {$wallet->owner->email} with {$charge}"
+ "Charged wallet {$wallet->id} for user {$wallet->owner->email} with {$charge}"
);
- $wallet->chargeEntitlements();
-
- if ($wallet->balance < 0) {
- // Disabled for now
- // \App\Jobs\WalletPayment::dispatch($wallet);
- }
+ // Top-up the wallet if auto-payment enabled for the wallet
+ \App\Jobs\WalletCharge::dispatch($wallet);
}
}
}
diff --git a/src/app/Console/Commands/WalletExpected.php b/src/app/Console/Commands/WalletExpected.php
--- a/src/app/Console/Commands/WalletExpected.php
+++ b/src/app/Console/Commands/WalletExpected.php
@@ -2,11 +2,7 @@
namespace App\Console\Commands;
-use App\Domain;
-use App\User;
-use Carbon\Carbon;
use Illuminate\Console\Command;
-use Illuminate\Support\Facades\DB;
class WalletExpected extends Command
{
diff --git a/src/app/Console/Commands/WalletMandate.php b/src/app/Console/Commands/WalletMandate.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/WalletMandate.php
@@ -0,0 +1,70 @@
+argument('wallet'));
+
+ if (!$wallet) {
+ return 1;
+ }
+
+ $mandate = PaymentsController::walletMandate($wallet);
+
+ if (!empty($mandate['id'])) {
+ $disabled = $mandate['isDisabled'] ? 'Yes' : 'No';
+
+ if ($this->option('disable') && $disabled == 'No') {
+ $wallet->setSetting('mandate_disabled', 1);
+ $disabled = 'Yes';
+ } elseif ($this->option('enable') && $disabled == 'Yes') {
+ $wallet->setSetting('mandate_disabled', null);
+ $disabled = 'No';
+ }
+
+ $this->info("Auto-payment: {$mandate['method']}");
+ $this->info(" id: {$mandate['id']}");
+ $this->info(" status: " . ($mandate['isPending'] ? 'pending' : 'valid'));
+ $this->info(" amount: {$mandate['amount']} {$wallet->currency}");
+ $this->info(" min-balance: {$mandate['balance']} {$wallet->currency}");
+ $this->info(" disabled: $disabled");
+ } else {
+ $this->info("Auto-payment: none");
+ }
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php
--- a/src/app/Http/Controllers/API/V4/PaymentsController.php
+++ b/src/app/Http/Controllers/API/V4/PaymentsController.php
@@ -65,8 +65,10 @@
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
- $wallet->setSetting('mandate_amount', $request->amount);
- $wallet->setSetting('mandate_balance', $request->balance);
+ $wallet->setSettings([
+ 'mandate_amount' => $request->amount,
+ 'mandate_balance' => $request->balance,
+ ]);
$request = [
'currency' => 'CHF',
@@ -99,6 +101,8 @@
$provider->deleteMandate($wallet);
+ $wallet->setSetting('mandate_disabled', null);
+
return response()->json([
'status' => 'success',
'message' => \trans('app.mandate-delete-success'),
@@ -142,13 +146,31 @@
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
- $wallet->setSetting('mandate_amount', $request->amount);
- $wallet->setSetting('mandate_balance', $request->balance);
+ // If the mandate is disabled the update will trigger
+ // an auto-payment and the amount must cover the debt
+ if ($wallet->getSetting('mandate_disabled')) {
+ if ($wallet->balance < 0 && $wallet->balance + $amount < 0) {
+ $errors = ['amount' => \trans('validation.minamountdebt')];
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
- return response()->json([
- 'status' => 'success',
- 'message' => \trans('app.mandate-update-success'),
+ $wallet->setSetting('mandate_disabled', null);
+
+ if ($wallet->balance < intval($request->balance * 100)) {
+ \App\Jobs\WalletCharge::dispatch($wallet);
+ }
+ }
+
+ $wallet->setSettings([
+ 'mandate_amount' => $request->amount,
+ 'mandate_balance' => $request->balance,
]);
+
+ $result = self::walletMandate($wallet);
+ $result['status'] = 'success';
+ $result['message'] = \trans('app.mandate-update-success');
+
+ return response()->json($result);
}
/**
@@ -222,15 +244,43 @@
}
/**
- * Charge a wallet with a "recurring" payment.
+ * Top up a wallet with a "recurring" payment.
*
* @param \App\Wallet $wallet The wallet to charge
- * @param int $amount The amount of money in cents
*
- * @return bool
+ * @return bool True if the payment has been initialized
*/
- public static function directCharge(Wallet $wallet, $amount): bool
+ public static function topUpWallet(Wallet $wallet): bool
{
+ if ((bool) $wallet->getSetting('mandate_disabled')) {
+ return false;
+ }
+
+ $min_balance = (int) (floatval($wallet->getSetting('mandate_balance')) * 100);
+ $amount = (int) (floatval($wallet->getSetting('mandate_amount')) * 100);
+
+ // The wallet balance is greater than the auto-payment threshold
+ if ($wallet->balance >= $min_balance) {
+ // Do nothing
+ return false;
+ }
+
+ // The defined top-up amount is not enough
+ // Disable auto-payment and notify the user
+ if ($wallet->balance + $amount < 0) {
+ // Disable (not remove) the mandate
+ $wallet->setSetting('mandate_disabled', 1);
+ \App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet);
+ return false;
+ }
+
+ $provider = PaymentProvider::factory($wallet);
+ $mandate = (array) $provider->getMandate($wallet);
+
+ if (empty($mandate['isValid'])) {
+ return false;
+ }
+
$request = [
'type' => PaymentProvider::TYPE_RECURRING,
'currency' => 'CHF',
@@ -238,13 +288,9 @@
'description' => \config('app.name') . ' Recurring Payment',
];
- $provider = PaymentProvider::factory($wallet);
-
- if ($result = $provider->payment($wallet, $request)) {
- return true;
- }
+ $result = $provider->payment($wallet, $request);
- return false;
+ return !empty($result);
}
/**
@@ -263,6 +309,7 @@
$mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100);
$mandate['balance'] = 0;
+ $mandate['isDisabled'] = !empty($mandate['id']) && $wallet->getSetting('mandate_disabled');
foreach (['amount', 'balance'] as $key) {
if (($value = $wallet->getSetting("mandate_{$key}")) !== null) {
diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php
--- a/src/app/Http/Kernel.php
+++ b/src/app/Http/Kernel.php
@@ -40,7 +40,7 @@
],
'api' => [
- 'throttle:60,1',
+ 'throttle:120,1',
'bindings',
],
];
diff --git a/src/app/Jobs/PaymentEmail.php b/src/app/Jobs/PaymentEmail.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/PaymentEmail.php
@@ -0,0 +1,94 @@
+payment = $payment;
+ $this->controller = $controller;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $wallet = $this->payment->wallet;
+
+ if (empty($this->controller)) {
+ $this->controller = $wallet->owner;
+ }
+
+ $ext_email = $this->controller->getSetting('external_email');
+ $cc = [];
+
+ if ($ext_email && $ext_email != $this->controller->email) {
+ $cc[] = $ext_email;
+ }
+
+ if ($this->payment->status == PaymentProvider::STATUS_PAID) {
+ $mail = new \App\Mail\PaymentSuccess($this->payment, $this->controller);
+ } elseif (
+ $this->payment->status == PaymentProvider::STATUS_EXPIRED
+ || $this->payment->status == PaymentProvider::STATUS_FAILED
+ ) {
+ $mail = new \App\Mail\PaymentFailure($this->payment, $this->controller);
+ } else {
+ return;
+ }
+
+ Mail::to($this->controller->email)->cc($cc)->send($mail);
+
+ /*
+ // Send the email to all wallet controllers too
+ if ($wallet->owner->id == $this->controller->id) {
+ $this->wallet->controllers->each(function ($controller) {
+ self::dispatch($this->payment, $controller);
+ }
+ });
+ */
+ }
+}
diff --git a/src/app/Jobs/PaymentMandateDisabledEmail.php b/src/app/Jobs/PaymentMandateDisabledEmail.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/PaymentMandateDisabledEmail.php
@@ -0,0 +1,83 @@
+wallet = $wallet;
+ $this->controller = $controller;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ if (empty($this->controller)) {
+ $this->controller = $this->wallet->owner;
+ }
+
+ $ext_email = $this->controller->getSetting('external_email');
+ $cc = [];
+
+ if ($ext_email && $ext_email != $this->controller->email) {
+ $cc[] = $ext_email;
+ }
+
+ $mail = new PaymentMandateDisabled($this->wallet, $this->controller);
+
+ Mail::to($this->controller->email)->cc($cc)->send($mail);
+
+ /*
+ // Send the email to all controllers too
+ if ($this->controller->id == $this->wallet->owner->id) {
+ $this->wallet->controllers->each(function ($controller) {
+ self::dispatch($this->wallet, $controller);
+ }
+ });
+ */
+ }
+}
diff --git a/src/app/Jobs/WalletPayment.php b/src/app/Jobs/WalletCharge.php
rename from src/app/Jobs/WalletPayment.php
rename to src/app/Jobs/WalletCharge.php
--- a/src/app/Jobs/WalletPayment.php
+++ b/src/app/Jobs/WalletCharge.php
@@ -10,25 +10,30 @@
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
-class WalletPayment implements ShouldQueue
+class WalletCharge implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
+ /** @var \App\Wallet A wallet object */
protected $wallet;
+ /** @var int The number of seconds to wait before retrying the job. */
+ public $retryAfter = 10;
+
+ /** @var int How many times retry the job if it fails. */
public $tries = 5;
- /** @var bool Delete the job if its models no longer exist. */
+ /** @var bool Delete the job if the wallet no longer exist. */
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
- * @param \App\Wallet $wallet The wallet to charge.
+ * @param \App\Wallet $wallet The wallet that has been charged.
*
* @return void
*/
@@ -44,8 +49,6 @@
*/
public function handle()
{
- if ($this->wallet->balance < 0) {
- PaymentsController::directCharge($this->wallet, $this->wallet->balance * -1);
- }
+ PaymentsController::topUpWallet($this->wallet);
}
}
diff --git a/src/app/Mail/PasswordReset.php b/src/app/Mail/PasswordReset.php
--- a/src/app/Mail/PasswordReset.php
+++ b/src/app/Mail/PasswordReset.php
@@ -3,6 +3,7 @@
namespace App\Mail;
use App\User;
+use App\Utils;
use App\VerificationCode;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
@@ -37,11 +38,8 @@
*/
public function build()
{
- $href = sprintf(
- '%s/login/reset/%s-%s',
- \config('app.url'),
- $this->code->short_code,
- $this->code->code
+ $href = Utils::serviceUrl(
+ sprintf('/login/reset/%s-%s', $this->code->short_code, $this->code->code)
);
$this->view('emails.password_reset')
diff --git a/src/app/Mail/SuspendedDebtor.php b/src/app/Mail/PaymentFailure.php
copy from src/app/Mail/SuspendedDebtor.php
copy to src/app/Mail/PaymentFailure.php
--- a/src/app/Mail/SuspendedDebtor.php
+++ b/src/app/Mail/PaymentFailure.php
@@ -2,31 +2,37 @@
namespace App\Mail;
+use App\Payment;
use App\User;
use App\Utils;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
-class SuspendedDebtor extends Mailable
+class PaymentFailure extends Mailable
{
use Queueable;
use SerializesModels;
- /** @var \App\User A suspended user (account) */
- protected $account;
+ /** @var \App\Payment A payment operation */
+ protected $payment;
+
+ /** @var \App\User A wallet controller to whom the email is being send */
+ protected $user;
/**
* Create a new message instance.
*
- * @param \App\User $account A suspended user (account)
+ * @param \App\Payment $payment A payment operation
+ * @param \App\User $user An email recipient
*
* @return void
*/
- public function __construct(User $account)
+ public function __construct(Payment $payment, User $user)
{
- $this->account = $account;
+ $this->payment = $payment;
+ $this->user = $user;
}
/**
@@ -36,26 +42,18 @@
*/
public function build()
{
- $user = $this->account;
-
- $subject = \trans('mail.suspendeddebtor-subject', ['site' => \config('app.name')]);
+ $user = $this->user;
- $moreInfo = null;
- if ($moreInfoUrl = \config('app.kb.account_suspended')) {
- $moreInfo = \trans('mail.more-info-html', ['href' => $moreInfoUrl]);
- }
+ $subject = \trans('mail.paymentfailure-subject', ['site' => \config('app.name')]);
- $this->view('emails.suspended_debtor')
+ $this->view('emails.payment_failure')
->subject($subject)
->with([
'site' => \config('app.name'),
'subject' => $subject,
'username' => $user->name(true),
- 'cancelUrl' => \config('app.kb.account_delete'),
- 'supportUrl' => \config('app.support_url'),
'walletUrl' => Utils::serviceUrl('/wallet'),
- 'moreInfo' => $moreInfo,
- 'days' => 14 // TODO: Configurable
+ 'supportUrl' => \config('app.support_url'),
]);
return $this;
@@ -68,9 +66,16 @@
*/
public static function fakeRender(): string
{
- $user = new User();
+ $payment = new Payment();
+ $user = new User([
+ 'email' => 'test@' . \config('app.domain'),
+ ]);
+
+ if (!\config('app.support_url')) {
+ \config(['app.support_url' => 'https://not-configured-support.url']);
+ }
- $mail = new self($user);
+ $mail = new self($payment, $user);
return $mail->build()->render();
}
diff --git a/src/app/Mail/SuspendedDebtor.php b/src/app/Mail/PaymentMandateDisabled.php
copy from src/app/Mail/SuspendedDebtor.php
copy to src/app/Mail/PaymentMandateDisabled.php
--- a/src/app/Mail/SuspendedDebtor.php
+++ b/src/app/Mail/PaymentMandateDisabled.php
@@ -4,29 +4,35 @@
use App\User;
use App\Utils;
+use App\Wallet;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
-class SuspendedDebtor extends Mailable
+class PaymentMandateDisabled extends Mailable
{
use Queueable;
use SerializesModels;
- /** @var \App\User A suspended user (account) */
- protected $account;
+ /** @var \App\Wallet A wallet for which the mandate has been disabled */
+ protected $wallet;
+
+ /** @var \App\User A wallet controller to whom the email is being send */
+ protected $user;
/**
* Create a new message instance.
*
- * @param \App\User $account A suspended user (account)
+ * @param \App\Wallet $wallet A wallet that has been charged
+ * @param \App\User $user An email recipient
*
* @return void
*/
- public function __construct(User $account)
+ public function __construct(Wallet $wallet, User $user)
{
- $this->account = $account;
+ $this->wallet = $wallet;
+ $this->user = $user;
}
/**
@@ -36,26 +42,18 @@
*/
public function build()
{
- $user = $this->account;
-
- $subject = \trans('mail.suspendeddebtor-subject', ['site' => \config('app.name')]);
+ $user = $this->user;
- $moreInfo = null;
- if ($moreInfoUrl = \config('app.kb.account_suspended')) {
- $moreInfo = \trans('mail.more-info-html', ['href' => $moreInfoUrl]);
- }
+ $subject = \trans('mail.paymentmandatedisabled-subject', ['site' => \config('app.name')]);
- $this->view('emails.suspended_debtor')
+ $this->view('emails.payment_mandate_disabled')
->subject($subject)
->with([
'site' => \config('app.name'),
'subject' => $subject,
'username' => $user->name(true),
- 'cancelUrl' => \config('app.kb.account_delete'),
- 'supportUrl' => \config('app.support_url'),
'walletUrl' => Utils::serviceUrl('/wallet'),
- 'moreInfo' => $moreInfo,
- 'days' => 14 // TODO: Configurable
+ 'supportUrl' => \config('app.support_url'),
]);
return $this;
@@ -68,9 +66,16 @@
*/
public static function fakeRender(): string
{
- $user = new User();
+ $wallet = new Wallet();
+ $user = new User([
+ 'email' => 'test@' . \config('app.domain'),
+ ]);
+
+ if (!\config('app.support_url')) {
+ \config(['app.support_url' => 'https://not-configured-support.url']);
+ }
- $mail = new self($user);
+ $mail = new self($wallet, $user);
return $mail->build()->render();
}
diff --git a/src/app/Mail/SuspendedDebtor.php b/src/app/Mail/PaymentSuccess.php
copy from src/app/Mail/SuspendedDebtor.php
copy to src/app/Mail/PaymentSuccess.php
--- a/src/app/Mail/SuspendedDebtor.php
+++ b/src/app/Mail/PaymentSuccess.php
@@ -2,31 +2,37 @@
namespace App\Mail;
+use App\Payment;
use App\User;
use App\Utils;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
-class SuspendedDebtor extends Mailable
+class PaymentSuccess extends Mailable
{
use Queueable;
use SerializesModels;
- /** @var \App\User A suspended user (account) */
- protected $account;
+ /** @var \App\Payment A payment operation */
+ protected $payment;
+
+ /** @var \App\User A wallet controller to whom the email is being send */
+ protected $user;
/**
* Create a new message instance.
*
- * @param \App\User $account A suspended user (account)
+ * @param \App\Payment $payment A payment operation
+ * @param \App\User $user An email recipient
*
* @return void
*/
- public function __construct(User $account)
+ public function __construct(Payment $payment, User $user)
{
- $this->account = $account;
+ $this->payment = $payment;
+ $this->user = $user;
}
/**
@@ -36,26 +42,18 @@
*/
public function build()
{
- $user = $this->account;
-
- $subject = \trans('mail.suspendeddebtor-subject', ['site' => \config('app.name')]);
+ $user = $this->user;
- $moreInfo = null;
- if ($moreInfoUrl = \config('app.kb.account_suspended')) {
- $moreInfo = \trans('mail.more-info-html', ['href' => $moreInfoUrl]);
- }
+ $subject = \trans('mail.paymentsuccess-subject', ['site' => \config('app.name')]);
- $this->view('emails.suspended_debtor')
+ $this->view('emails.payment_success')
->subject($subject)
->with([
'site' => \config('app.name'),
'subject' => $subject,
'username' => $user->name(true),
- 'cancelUrl' => \config('app.kb.account_delete'),
- 'supportUrl' => \config('app.support_url'),
'walletUrl' => Utils::serviceUrl('/wallet'),
- 'moreInfo' => $moreInfo,
- 'days' => 14 // TODO: Configurable
+ 'supportUrl' => \config('app.support_url'),
]);
return $this;
@@ -68,9 +66,16 @@
*/
public static function fakeRender(): string
{
- $user = new User();
+ $payment = new Payment();
+ $user = new User([
+ 'email' => 'test@' . \config('app.domain'),
+ ]);
+
+ if (!\config('app.support_url')) {
+ \config(['app.support_url' => 'https://not-configured-support.url']);
+ }
- $mail = new self($user);
+ $mail = new self($payment, $user);
return $mail->build()->render();
}
diff --git a/src/app/Mail/SignupVerification.php b/src/app/Mail/SignupVerification.php
--- a/src/app/Mail/SignupVerification.php
+++ b/src/app/Mail/SignupVerification.php
@@ -3,6 +3,7 @@
namespace App\Mail;
use App\SignupCode;
+use App\Utils;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
@@ -36,11 +37,8 @@
*/
public function build()
{
- $href = sprintf(
- '%s/signup/%s-%s',
- \config('app.url'),
- $this->code->short_code,
- $this->code->code
+ $href = Utils::serviceUrl(
+ sprintf('/signup/%s-%s', $this->code->short_code, $this->code->code)
);
$username = $this->code->data['first_name'] ?? '';
diff --git a/src/app/Mail/SuspendedDebtor.php b/src/app/Mail/SuspendedDebtor.php
--- a/src/app/Mail/SuspendedDebtor.php
+++ b/src/app/Mail/SuspendedDebtor.php
@@ -70,6 +70,18 @@
{
$user = new User();
+ if (!\config('app.support_url')) {
+ \config(['app.support_url' => 'https://not-configured-support.url']);
+ }
+
+ if (!\config('app.kb.account_delete')) {
+ \config(['app.kb.account_delete' => 'https://not-configured-kb.url']);
+ }
+
+ if (!\config('app.kb.account_suspended')) {
+ \config(['app.kb.account_suspended' => 'https://not-configured-kb.url']);
+ }
+
$mail = new self($user);
return $mail->build()->render();
diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php
--- a/src/app/Providers/Payment/Mollie.php
+++ b/src/app/Providers/Payment/Mollie.php
@@ -5,6 +5,7 @@
use App\Payment;
use App\Utils;
use App\Wallet;
+use Illuminate\Support\Facades\DB;
class Mollie extends \App\Providers\PaymentProvider
{
@@ -117,42 +118,9 @@
'id' => $mandate->id,
'isPending' => $mandate->isPending(),
'isValid' => $mandate->isValid(),
+ 'method' => self::paymentMethod($mandate, 'Unknown method')
];
- $details = $mandate->details;
-
- // Mollie supports 3 methods here
- switch ($mandate->method) {
- case 'creditcard':
- // If the customer started, but never finished the 'first' payment
- // card details will be empty, and mandate will be 'pending'.
- if (empty($details->cardNumber)) {
- $result['method'] = 'Credit Card';
- } else {
- $result['method'] = sprintf(
- '%s (**** **** **** %s)',
- $details->cardLabel ?: 'Card', // @phpstan-ignore-line
- $details->cardNumber
- );
- }
- break;
-
- case 'directdebit':
- $result['method'] = sprintf(
- 'Direct Debit (%s)',
- $details->customerAccount
- );
- break;
-
- case 'paypal':
- $result['method'] = sprintf('PayPal (%s)', $details->consumerAccount);
- break;
-
-
- default:
- $result['method'] = 'Unknown method';
- }
-
return $result;
}
@@ -182,6 +150,10 @@
*/
public function payment(Wallet $wallet, array $payment): ?array
{
+ if ($payment['type'] == self::TYPE_RECURRING) {
+ return $this->paymentRecurring($wallet, $payment);
+ }
+
// Register the user in Mollie, if not yet done
$customer_id = self::mollieCustomerId($wallet);
@@ -199,26 +171,65 @@
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
// 'method' => 'creditcard',
+ 'redirectUrl' => \url('/wallet') // required for non-recurring payments
];
- if ($payment['type'] == self::TYPE_RECURRING) {
- // Check if there's a valid mandate
- $mandate = self::mollieMandate($wallet);
+ // TODO: Additional payment parameters for better fraud protection:
+ // billingEmail - for bank transfers, Przelewy24, but not creditcard
+ // billingAddress (it is a structured field not just text)
- if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) {
- return null;
- }
+ // Create the payment in Mollie
+ $response = mollie()->payments()->create($request);
+
+ // Store the payment reference in database
+ $payment['status'] = $response->status;
+ $payment['id'] = $response->id;
- $request['mandateId'] = $mandate->id;
- } else {
- // required for non-recurring payments
- $request['redirectUrl'] = \url('/wallet');
+ $this->storePayment($payment, $wallet->id);
- // TODO: Additional payment parameters for better fraud protection:
- // billingEmail - for bank transfers, Przelewy24, but not creditcard
- // billingAddress (it is a structured field not just text)
+ return [
+ 'id' => $payment['id'],
+ 'redirectUrl' => $response->getCheckoutUrl(),
+ ];
+ }
+
+ /**
+ * Create a new automatic payment operation.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data (see self::payment())
+ *
+ * @return array Provider payment/session data:
+ * - id: Operation identifier
+ */
+ protected function paymentRecurring(Wallet $wallet, array $payment): ?array
+ {
+ // Check if there's a valid mandate
+ $mandate = self::mollieMandate($wallet);
+
+ if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) {
+ return null;
}
+ $customer_id = self::mollieCustomerId($wallet);
+
+ // Note: Required fields: description, amount/currency, amount/value
+
+ $request = [
+ 'amount' => [
+ 'currency' => $payment['currency'],
+ // a number with two decimals is required
+ 'value' => sprintf('%.2f', $payment['amount'] / 100),
+ ],
+ 'customerId' => $customer_id,
+ 'sequenceType' => $payment['type'],
+ 'description' => $payment['description'],
+ 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
+ 'locale' => 'en_US',
+ // 'method' => 'creditcard',
+ 'mandateId' => $mandate->id
+ ];
+
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
@@ -226,11 +237,34 @@
$payment['status'] = $response->status;
$payment['id'] = $response->id;
- self::storePayment($payment, $wallet->id);
+ DB::beginTransaction();
+
+ $payment = $this->storePayment($payment, $wallet->id);
+
+ // Mollie can return 'paid' status immediately, so we don't
+ // have to wait for the webhook. What's more, the webhook would ignore
+ // the payment because it will be marked as paid before the webhook.
+ // Let's handle paid status here too.
+ if ($response->isPaid()) {
+ self::creditPayment($payment, $response);
+ $notify = true;
+ } elseif ($response->isFailed()) {
+ // Note: I didn't find a way to get any description of the problem with a payment
+ \Log::info(sprintf('Mollie payment failed (%s)', $response->id));
+
+ // Disable the mandate
+ $wallet->setSetting('mandate_disabled', 1);
+ $notify = true;
+ }
+
+ DB::commit();
+
+ if (!empty($notify)) {
+ \App\Jobs\PaymentEmail::dispatch($payment);
+ }
return [
'id' => $payment['id'],
- 'redirectUrl' => $response->getCheckoutUrl(),
];
}
@@ -267,7 +301,8 @@
// The payment is paid and isn't refunded or charged back.
// Update the balance, if it wasn't already
if ($payment->status != self::STATUS_PAID && $payment->amount > 0) {
- $payment->wallet->credit($payment->amount);
+ $credit = true;
+ $notify = $payment->type == self::TYPE_RECURRING;
}
} elseif ($mollie_payment->hasRefunds()) {
// The payment has been (partially) refunded.
@@ -281,15 +316,35 @@
} elseif ($mollie_payment->isFailed()) {
// Note: I didn't find a way to get any description of the problem with a payment
\Log::info(sprintf('Mollie payment failed (%s)', $payment->id));
+
+ // Disable the mandate
+ if ($payment->type == self::TYPE_RECURRING) {
+ $notify = true;
+ $payment->wallet->setSetting('mandate_disabled', 1);
+ }
}
+ DB::beginTransaction();
+
// This is a sanity check, just in case the payment provider api
- // sent us open -> paid -> open -> paid. So, we lock the payment after it's paid.
- if ($payment->status != self::STATUS_PAID) {
+ // sent us open -> paid -> open -> paid. So, we lock the payment after
+ // recivied a "final" state.
+ $pending_states = [self::STATUS_OPEN, self::STATUS_PENDING, self::STATUS_AUTHORIZED];
+ if (in_array($payment->status, $pending_states)) {
$payment->status = $mollie_payment->status;
$payment->save();
}
+ if (!empty($credit)) {
+ self::creditPayment($payment, $mollie_payment);
+ }
+
+ DB::commit();
+
+ if (!empty($notify)) {
+ \App\Jobs\PaymentEmail::dispatch($payment);
+ }
+
return 200;
}
@@ -346,4 +401,56 @@
}
*/
}
+
+ /**
+ * Apply the successful payment's pecunia to the wallet
+ */
+ protected static function creditPayment($payment, $mollie_payment)
+ {
+ // Extract the payment method for transaction description
+ $method = self::paymentMethod($mollie_payment, 'Mollie');
+
+ // TODO: Localization?
+ $description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment';
+ $description .= " transaction {$payment->id} using {$method}";
+
+ $payment->wallet->credit($payment->amount, $description);
+
+ // Unlock the disabled auto-payment mandate
+ if ($payment->wallet->balance >= 0) {
+ $payment->wallet->setSetting('mandate_disabled', null);
+ }
+ }
+
+ /**
+ * Extract payment method description from Mollie payment/mandate details
+ */
+ protected static function paymentMethod($object, $default = ''): string
+ {
+ $details = $object->details;
+
+ // Mollie supports 3 methods here
+ switch ($object->method) {
+ case 'creditcard':
+ // If the customer started, but never finished the 'first' payment
+ // card details will be empty, and mandate will be 'pending'.
+ if (empty($details->cardNumber)) {
+ return 'Credit Card';
+ }
+
+ return sprintf(
+ '%s (**** **** **** %s)',
+ $details->cardLabel ?: 'Card', // @phpstan-ignore-line
+ $details->cardNumber
+ );
+
+ case 'directdebit':
+ return sprintf('Direct Debit (%s)', $details->customerAccount);
+
+ case 'paypal':
+ return sprintf('PayPal (%s)', $details->consumerAccount);
+ }
+
+ return $default;
+ }
}
diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php
--- a/src/app/Providers/Payment/Stripe.php
+++ b/src/app/Providers/Payment/Stripe.php
@@ -6,6 +6,8 @@
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
@@ -73,6 +75,13 @@
$session = StripeAPI\Checkout\Session::create($request);
+ $payment = [
+ 'id' => $session->setup_intent,
+ 'type' => self::TYPE_MANDATE,
+ ];
+
+ $this->storePayment($payment, $wallet->id);
+
return [
'id' => $session->id,
];
@@ -128,25 +137,9 @@
'id' => $mandate->id,
'isPending' => $mandate->status != 'succeeded' && $mandate->status != 'canceled',
'isValid' => $mandate->status == 'succeeded',
+ 'method' => self::paymentMethod($pm, 'Unknown method')
];
- switch ($pm->type) {
- case 'card':
- // TODO: card number
- $result['method'] = \sprintf(
- '%s (**** **** **** %s)',
- // @phpstan-ignore-next-line
- \ucfirst($pm->card->brand) ?: 'Card',
- // @phpstan-ignore-next-line
- $pm->card->last4
- );
-
- break;
-
- default:
- $result['method'] = 'Unknown method';
- }
-
return $result;
}
@@ -201,10 +194,9 @@
$session = StripeAPI\Checkout\Session::create($request);
// Store the payment reference in database
- $payment['status'] = self::STATUS_OPEN;
$payment['id'] = $session->payment_intent;
- self::storePayment($payment, $wallet->id);
+ $this->storePayment($payment, $wallet->id);
return [
'id' => $session->id,
@@ -233,20 +225,19 @@
'amount' => $payment['amount'],
'currency' => \strtolower($payment['currency']),
'description' => $payment['description'],
- 'locale' => 'en',
- 'off_session' => true,
'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['status'] = self::STATUS_OPEN;
$payment['id'] = $intent->id;
- self::storePayment($payment, $wallet->id);
+ $this->storePayment($payment, $wallet->id);
return [
'id' => $payment['id'],
@@ -260,8 +251,11 @@
*/
public function webhook(): int
{
- $payload = file_get_contents('php://input');
- $sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'];
+ // 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 {
@@ -270,7 +264,7 @@
$sig_header,
\config('services.stripe.webhook_secret')
);
- } catch (\UnexpectedValueException $e) {
+ } catch (\Exception $e) {
// Invalid payload
return 400;
}
@@ -282,6 +276,10 @@
$intent = $event->data->object; // @phpstan-ignore-line
$payment = Payment::find($intent->id);
+ if (empty($payment) || $payment->type == self::TYPE_MANDATE) {
+ return 404;
+ }
+
switch ($intent->status) {
case StripeAPI\PaymentIntent::STATUS_CANCELED:
$status = self::STATUS_CANCELED;
@@ -290,43 +288,74 @@
$status = self::STATUS_PAID;
break;
default:
- $status = self::STATUS_PENDING;
+ $status = self::STATUS_FAILED;
}
+ DB::beginTransaction();
+
if ($status == self::STATUS_PAID) {
// Update the balance, if it wasn't already
if ($payment->status != self::STATUS_PAID) {
- $payment->wallet->credit($payment->amount);
+ $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)
+ ));
}
- } elseif (!empty($intent->last_payment_error)) {
- // See https://stripe.com/docs/error-codes for more info
- \Log::info(sprintf(
- 'Stripe payment failed (%s): %s',
- $payment->id,
- json_encode($intent->last_payment_error)
- ));
}
if ($payment->status != self::STATUS_PAID) {
$payment->status = $status;
$payment->save();
+
+ if ($status != self::STATUS_CANCELED && $payment->type == self::TYPE_RECURRING) {
+ // Disable the mandate
+ if ($status == self::STATUS_FAILED) {
+ $payment->wallet->setSetting('mandate_disabled', 1);
+ }
+
+ // Notify the user
+ \App\Jobs\PaymentEmail::dispatch($payment);
+ }
}
+ DB::commit();
+
break;
case StripeAPI\Event::SETUP_INTENT_SUCCEEDED:
+ case StripeAPI\Event::SETUP_INTENT_SETUP_FAILED:
+ case StripeAPI\Event::SETUP_INTENT_CANCELED:
$intent = $event->data->object; // @phpstan-ignore-line
+ $payment = Payment::find($intent->id);
- // Find the wallet
- // TODO: This query is potentially slow, we should find another way
- // Maybe use payment/transactions table to store the reference
- $setting = WalletSetting::where('key', 'stripe_id')
- ->where('value', $intent->customer)->first();
+ if (empty($payment) || $payment->type != self::TYPE_MANDATE) {
+ return 404;
+ }
- if ($setting) {
- $setting->wallet->setSetting('stripe_mandate_id', $intent->id);
+ switch ($intent->status) {
+ case StripeAPI\SetupIntent::STATUS_CANCELED:
+ $status = self::STATUS_CANCELED;
+ break;
+ case StripeAPI\SetupIntent::STATUS_SUCCEEDED:
+ $status = self::STATUS_PAID;
+ break;
+ default:
+ $status = self::STATUS_FAILED;
+ }
+
+ if ($status == self::STATUS_PAID) {
+ $payment->wallet->setSetting('stripe_mandate_id', $intent->id);
}
+ $payment->status = $status;
+ $payment->save();
+
break;
default:
@@ -382,4 +411,52 @@
}
}
}
+
+ /**
+ * Apply the successful payment's pecunia to the wallet
+ */
+ protected static function creditPayment(Payment $payment, $intent)
+ {
+ $method = 'Stripe';
+
+ // Extract the payment method for transaction description
+ if (
+ !empty($intent->charges)
+ && ($charge = $intent->charges->data[0])
+ && ($pm = $charge->payment_method_details)
+ ) {
+ $method = self::paymentMethod($pm);
+ }
+
+ // TODO: Localization?
+ $description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment';
+ $description .= " transaction {$payment->id} using {$method}";
+
+ $payment->wallet->credit($payment->amount, $description);
+
+ // Unlock the disabled auto-payment mandate
+ if ($payment->wallet->balance >= 0) {
+ $payment->wallet->setSetting('mandate_disabled', null);
+ }
+ }
+
+ /**
+ * Extract payment method description from Stripe payment details
+ */
+ protected static function paymentMethod($details, $default = ''): string
+ {
+ switch ($details->type) {
+ case 'card':
+ // TODO: card number
+ return \sprintf(
+ '%s (**** **** **** %s)',
+ // @phpstan-ignore-next-line
+ \ucfirst($details->card->brand) ?: 'Card',
+ // @phpstan-ignore-next-line
+ $details->card->last4
+ );
+ }
+
+ return $default;
+ }
}
diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php
--- a/src/app/Providers/PaymentProvider.php
+++ b/src/app/Providers/PaymentProvider.php
@@ -17,6 +17,7 @@
public const TYPE_ONEOFF = 'oneoff';
public const TYPE_RECURRING = 'recurring';
+ public const TYPE_MANDATE = 'mandate';
/** const int Minimum amount of money in a single payment (in cents) */
public const MIN_AMOUNT = 1000;
@@ -135,15 +136,21 @@
*
* @param array $payment Payment information
* @param string $wallet_id Wallet ID
+ *
+ * @return \App\Payment Payment object
*/
- protected static function storePayment(array $payment, $wallet_id): void
+ protected function storePayment(array $payment, $wallet_id): Payment
{
$db_payment = new Payment();
$db_payment->id = $payment['id'];
- $db_payment->description = $payment['description'];
- $db_payment->status = $payment['status'];
- $db_payment->amount = $payment['amount'];
+ $db_payment->description = $payment['description'] ?? '';
+ $db_payment->status = $payment['status'] ?? self::STATUS_OPEN;
+ $db_payment->amount = $payment['amount'] ?? 0;
+ $db_payment->type = $payment['type'];
$db_payment->wallet_id = $wallet_id;
+ $db_payment->provider = $this->name();
$db_payment->save();
+
+ return $db_payment;
}
}
diff --git a/src/app/Wallet.php b/src/app/Wallet.php
--- a/src/app/Wallet.php
+++ b/src/app/Wallet.php
@@ -117,6 +117,28 @@
}
/**
+ * A helper to display human-readable amount of money using
+ * the wallet currency and specified locale.
+ *
+ * @param int $amount A amount of money (in cents)
+ * @param string $locale A locale for the output
+ *
+ * @return string String representation, e.g. "9.99 CHF"
+ */
+ public function money(int $amount, $locale = 'de_DE')
+ {
+ $amount = round($amount / 100, 2);
+
+ // Prefer intl extension's number formatter
+ if (class_exists('NumberFormatter')) {
+ $nf = new \NumberFormatter($locale, \NumberFormatter::DECIMAL);
+ return $nf->formatCurrency($amount, $this->currency);
+ }
+
+ return sprintf('%.2f %s', $amount, $this->currency);
+ }
+
+ /**
* Controllers of this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
@@ -134,11 +156,12 @@
/**
* Add an amount of pecunia to this wallet's balance.
*
- * @param int $amount The amount of pecunia to add (in cents).
+ * @param int $amount The amount of pecunia to add (in cents).
+ * @param string $description The transaction description
*
* @return Wallet Self
*/
- public function credit(int $amount): Wallet
+ public function credit(int $amount, string $description = ''): Wallet
{
$this->balance += $amount;
@@ -150,7 +173,8 @@
'object_id' => $this->id,
'object_type' => \App\Wallet::class,
'type' => \App\Transaction::WALLET_CREDIT,
- 'amount' => $amount
+ 'amount' => $amount,
+ 'description' => $description
]
);
diff --git a/src/config/app.php b/src/config/app.php
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -57,6 +57,8 @@
'asset_url' => env('ASSET_URL', null),
+ 'support_url' => env('SUPPORT_URL', null),
+
/*
|--------------------------------------------------------------------------
| Application Domain
@@ -145,7 +147,6 @@
*/
'providers' => [
-
/*
* Laravel Framework Service Providers...
*/
@@ -198,7 +199,6 @@
*/
'aliases' => [
-
'App' => Illuminate\Support\Facades\App::class,
'Arr' => Illuminate\Support\Arr::class,
'Artisan' => Illuminate\Support\Facades\Artisan::class,
@@ -234,7 +234,6 @@
'URL' => Illuminate\Support\Facades\URL::class,
'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class,
-
],
// Locations of knowledge base articles
diff --git a/src/database/migrations/2020_03_16_100000_create_payments.php b/src/database/migrations/2020_03_16_100000_create_payments.php
--- a/src/database/migrations/2020_03_16_100000_create_payments.php
+++ b/src/database/migrations/2020_03_16_100000_create_payments.php
@@ -22,6 +22,8 @@
$table->string('status', 16);
$table->integer('amount');
$table->text('description');
+ $table->string('provider', 16);
+ $table->string('type', 16);
$table->timestamps();
$table->foreign('wallet_id')->references('id')->on('wallets')->onDelete('cascade');
diff --git a/src/phpunit.xml b/src/phpunit.xml
--- a/src/phpunit.xml
+++ b/src/phpunit.xml
@@ -33,7 +33,6 @@
{{ __('mail.header', ['name' => $username]) }}
+ +{{ __('mail.paymentfailure-body', ['site' => $site]) }}
+ +{{ __('mail.paymentfailure-body-rest', ['site' => $site]) }}
+ +@if ($supportUrl) +{{ __('mail.support', ['site' => $site]) }}
+ +@endif + +{{ __('mail.footer', ['site' => $site, 'appurl' => config('app.url')]) }}
+ + diff --git a/src/resources/views/emails/payment_mandate_disabled.blade.php b/src/resources/views/emails/payment_mandate_disabled.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/payment_mandate_disabled.blade.php @@ -0,0 +1,20 @@ + + + + + + +{{ __('mail.header', ['name' => $username]) }}
+ +{{ __('mail.paymentmandatedisabled-body', ['site' => $site]) }}
+ +{{ __('mail.paymentfailure-body-rest', ['site' => $site]) }}
+ +@if ($supportUrl) +{{ __('mail.support', ['site' => $site]) }}
+ +@endif + +{{ __('mail.footer', ['site' => $site, 'appurl' => config('app.url')]) }}
+ + diff --git a/src/resources/views/emails/payment_success.blade.php b/src/resources/views/emails/payment_success.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/payment_success.blade.php @@ -0,0 +1,19 @@ + + + + + + +{{ __('mail.header', ['name' => $username]) }}
+ +{{ __('mail.paymentsuccess-body', ['site' => $site]) }}
+ + +@if ($supportUrl) +{{ __('mail.support', ['site' => $site]) }}
+ +@endif + +{{ __('mail.footer', ['site' => $site, 'appurl' => config('app.url')]) }}
+ + diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -128,7 +128,7 @@ Fill up by {{ wallet.mandate.amount }} CHF when under {{ wallet.mandate.balance }} CHF - using {{ wallet.mandate.method }}. + using {{ wallet.mandate.method }} (disabled). diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue --- a/src/resources/vue/Wallet.vue +++ b/src/resources/vue/Wallet.vue @@ -51,6 +51,10 @@ every time your account balance gets under {{ mandate.balance }} CHF. You will be charged via {{ mandate.method }}. ++ The configured auto-payment has been disabled. Top up your wallet or + raise the auto-payment amount. +
You can cancel or change the auto-payment at any time.
+ The auto-payment is disabled. Immediately after you submit new settings we'll + attempt to top up your wallet. +