Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117891265
D1231.1775364479.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
68 KB
Referenced Files
None
Subscribers
None
D1231.1775364479.diff
View Options
diff --git a/src/.env.example b/src/.env.example
--- a/src/.env.example
+++ b/src/.env.example
@@ -60,7 +60,11 @@
SWOOLE_HTTP_HOST=127.0.0.1
SWOOLE_HTTP_PORT=8000
+PAYMENT_PROVIDER=
MOLLIE_KEY=
+STRIPE_KEY=
+STRIPE_PUBLIC_KEY=
+STRIPE_WEBHOOK_SECRET=
MAIL_DRIVER=smtp
MAIL_HOST=smtp.mailtrap.io
diff --git a/src/app/Console/Commands/MollieInfo.php b/src/app/Console/Commands/MollieInfo.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/MollieInfo.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\User;
+use Illuminate\Console\Command;
+
+class MollieInfo extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'mollie:info {user?}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Mollie information';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ if ($this->argument('user')) {
+ $user = User::where('email', $this->argument('user'))->first();
+
+ if (!$user) {
+ return 1;
+ }
+
+ $this->info("Found user: {$user->id}");
+
+ $wallet = $user->wallets->first();
+ $provider = new \App\Providers\Payment\Mollie();
+
+ if ($mandate = $provider->getMandate($wallet)) {
+ $amount = $wallet->getSetting('mandate_amount');
+ $balance = $wallet->getSetting('mandate_balance') ?: 0;
+
+ $this->info("Auto-payment: {$mandate['method']}");
+ $this->info(" id: {$mandate['id']}");
+ $this->info(" status: " . ($mandate['isPending'] ? 'pending' : 'valid'));
+ $this->info(" amount: {$amount} {$wallet->currency}");
+ $this->info(" min-balance: {$balance} {$wallet->currency}");
+ } else {
+ $this->info("Auto-payment: none");
+ }
+
+ // TODO: List user payments history
+ } else {
+ $this->info("Available payment methods:");
+
+ foreach (mollie()->methods()->all() as $method) {
+ $this->info("- {$method->description} ({$method->id}):");
+ $this->info(" status: {$method->status}");
+ $this->info(sprintf(
+ " min: %s %s",
+ $method->minimumAmount->value,
+ $method->minimumAmount->currency
+ ));
+ if (!empty($method->maximumAmount)) {
+ $this->info(sprintf(
+ " max: %s %s",
+ $method->maximumAmount->value,
+ $method->maximumAmount->currency
+ ));
+ }
+ }
+ }
+ }
+}
diff --git a/src/app/Console/Commands/StripeInfo.php b/src/app/Console/Commands/StripeInfo.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/StripeInfo.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Providers\PaymentProvider;
+use App\User;
+use Illuminate\Console\Command;
+use Stripe as StripeAPI;
+
+class StripeInfo extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'stripe:info {user?}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Stripe information';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ if ($this->argument('user')) {
+ $user = User::where('email', $this->argument('user'))->first();
+
+ if (!$user) {
+ return 1;
+ }
+
+ $this->info("Found user: {$user->id}");
+
+ $wallet = $user->wallets->first();
+ $provider = PaymentProvider::factory('stripe');
+
+ if ($mandate = $provider->getMandate($wallet)) {
+ $amount = $wallet->getSetting('mandate_amount');
+ $balance = $wallet->getSetting('mandate_balance') ?: 0;
+
+ $this->info("Auto-payment: {$mandate['method']}");
+ $this->info(" id: {$mandate['id']}");
+ $this->info(" status: " . ($mandate['isPending'] ? 'pending' : 'valid'));
+ $this->info(" amount: {$amount} {$wallet->currency}");
+ $this->info(" min-balance: {$balance} {$wallet->currency}");
+ } else {
+ $this->info("Auto-payment: none");
+ }
+
+ // TODO: List user payments history
+ } else {
+ // TODO: Fetch some info/stats from Stripe
+ }
+ }
+}
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
@@ -2,9 +2,9 @@
namespace App\Http\Controllers\API\V4;
-use App\Payment;
-use App\Wallet;
use App\Http\Controllers\Controller;
+use App\Providers\PaymentProvider;
+use App\Wallet;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
@@ -12,209 +12,247 @@
class PaymentsController extends Controller
{
/**
- * Create a new payment.
+ * Get the auto-payment mandate info.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function mandate()
+ {
+ $user = Auth::guard()->user();
+
+ // TODO: Wallet selection
+ $wallet = $user->wallets()->first();
+
+ $provider = PaymentProvider::factory();
+
+ // Get the Mandate info
+ $mandate = (array) $provider->getMandate($wallet);
+
+ $mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100);
+ $mandate['balance'] = 0;
+
+ foreach (['amount', 'balance'] as $key) {
+ if (($value = $wallet->getSetting("mandate_{$key}")) !== null) {
+ $mandate[$key] = $value;
+ }
+ }
+
+ 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 store(Request $request)
+ public function mandateCreate(Request $request)
{
$current_user = Auth::guard()->user();
// TODO: Wallet selection
$wallet = $current_user->wallets()->first();
+ $rules = [
+ 'mandate_amount' => 'required|numeric|min:0',
+ 'mandate_balance' => 'required|numeric|min:0',
+ ];
+
// Check required fields
- $v = Validator::make(
- $request->all(),
- [
- 'amount' => 'required|int|min:1',
- ]
- );
+ $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);
}
- // Register the user in Mollie, if not yet done
- // FIXME: Maybe Mollie ID should be bound to a wallet, but then
- // The same customer could technicly have multiple
- // Mollie IDs, then we'd need to use some "virtual" email
- // address (e.g. <wallet-id>@<user-domain>) instead of the user email address
- $customer_id = $current_user->getSetting('mollie_id');
- $seq_type = 'oneoff';
-
- if (empty($customer_id)) {
- $customer = mollie()->customers()->create([
- 'name' => $current_user->name(),
- 'email' => $current_user->email,
- ]);
-
- $seq_type = 'first';
- $customer_id = $customer->id;
- $current_user->setSetting('mollie_id', $customer_id);
+ $amount = (int) ($request->mandate_amount * 100);
+
+ // Validate the minimum value
+ if ($amount < PaymentProvider::MIN_AMOUNT) {
+ $errors = ['mandate_amount' => \trans('validation.minamount')];
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
- $payment_request = [
- 'amount' => [
- 'currency' => 'CHF',
- // a number with two decimals is required
- 'value' => sprintf('%.2f', $request->amount / 100),
- ],
- 'customerId' => $customer_id,
- 'sequenceType' => $seq_type, // 'first' / 'oneoff' / 'recurring'
- 'description' => 'Kolab Now Payment', // required
- 'redirectUrl' => \url('/wallet'), // required for non-recurring payments
- 'webhookUrl' => self::serviceUrl('/api/webhooks/payment/mollie'),
- 'locale' => 'en_US',
+ $wallet->setSetting('mandate_amount', $request->mandate_amount);
+ $wallet->setSetting('mandate_balance', $request->mandate_balance);
+
+ $request = [
+ 'currency' => 'CHF',
+ 'amount' => $amount,
+ 'description' => \config('app.name') . ' Auto-Payment Setup',
];
- // Create the payment in Mollie
- $payment = mollie()->payments()->create($payment_request);
+ $provider = PaymentProvider::factory();
+
+ $result = $provider->createMandate($wallet, $request);
+
+ $result['status'] = 'success';
+
+ return response()->json($result);
+ }
- // Store the payment reference in database
- self::storePayment($payment, $wallet->id, $request->amount);
+ /**
+ * Revoke the auto-payment mandate.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function mandateDelete()
+ {
+ $user = Auth::guard()->user();
+
+ // TODO: Wallet selection
+ $wallet = $user->wallets()->first();
+
+ $provider = PaymentProvider::factory();
+
+ $provider->deleteMandate($wallet);
return response()->json([
'status' => 'success',
- 'redirectUrl' => $payment->getCheckoutUrl(),
+ 'message' => \trans('app.mandate-delete-success'),
]);
}
/**
- * Update payment status (and balance).
+ * Update a new auto-payment mandate.
*
* @param \Illuminate\Http\Request $request The API request.
*
- * @return \Illuminate\Http\Response The response
+ * @return \Illuminate\Http\JsonResponse The response
*/
- public function webhook(Request $request)
+ public function mandateUpdate(Request $request)
{
- $db_payment = Payment::find($request->id);
+ $current_user = Auth::guard()->user();
- // Mollie recommends to return "200 OK" even if the payment does not exist
- if (empty($db_payment)) {
- return response('Success', 200);
- }
+ // TODO: Wallet selection
+ $wallet = $current_user->wallets()->first();
+
+ $rules = [
+ 'mandate_amount' => 'required|numeric|min:0',
+ 'mandate_balance' => 'required|numeric|min:0',
+ ];
- // Get the payment details from Mollie
- $payment = mollie()->payments()->get($request->id);
+ // Check required fields
+ $v = Validator::make($request->all(), $rules);
- if (empty($payment)) {
- return response('Success', 200);
- }
+ // TODO: allow comma as a decimal point?
- if ($payment->isPaid()) {
- if (!$payment->hasRefunds() && !$payment->hasChargebacks()) {
- // The payment is paid and isn't refunded or charged back.
- // Update the balance, if it wasn't already
- if ($db_payment->status != 'paid') {
- $db_payment->wallet->credit($db_payment->amount);
- }
- } elseif ($payment->hasRefunds()) {
- // The payment has been (partially) refunded.
- // The status of the payment is still "paid"
- // TODO: Update balance
- } elseif ($payment->hasChargebacks()) {
- // The payment has been (partially) charged back.
- // The status of the payment is still "paid"
- // TODO: Update balance
- }
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
- // 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 ($db_payment->status != 'paid') {
- $db_payment->status = $payment->status;
- $db_payment->save();
+ $amount = (int) ($request->mandate_amount * 100);
+
+ // Validate the minimum value
+ if ($amount < PaymentProvider::MIN_AMOUNT) {
+ $errors = ['mandate_amount' => \trans('validation.minamount')];
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
- return response('Success', 200);
+ $wallet->setSetting('mandate_amount', $request->mandate_amount);
+ $wallet->setSetting('mandate_balance', $request->mandate_balance);
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.mandate-update-success'),
+ ]);
}
/**
- * Charge a wallet with a "recurring" payment.
+ * Create a new payment.
*
- * @param \App\Wallet $wallet The wallet to charge
- * @param int $amount The amount of money in cents
+ * @param \Illuminate\Http\Request $request The API request.
*
- * @return bool
+ * @return \Illuminate\Http\JsonResponse The response
*/
- public static function directCharge(Wallet $wallet, $amount): bool
+ public function store(Request $request)
{
- $customer_id = $wallet->owner->getSetting('mollie_id');
+ $current_user = Auth::guard()->user();
+
+ // TODO: Wallet selection
+ $wallet = $current_user->wallets()->first();
+
+ $rules = [
+ 'amount' => 'required|numeric|min:0',
+ ];
+
+ // Check required fields
+ $v = Validator::make($request->all(), $rules);
+
+ // TODO: allow comma as a decimal point?
- if (empty($customer_id)) {
- return false;
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
- // Check if there's at least one valid mandate
- $mandates = mollie()->mandates()->listFor($customer_id)->filter(function ($mandate) {
- return $mandate->isValid();
- });
+ $amount = (int) ($request->amount * 100);
- if (empty($mandates)) {
- return false;
+ // Validate the minimum value
+ if ($amount < PaymentProvider::MIN_AMOUNT) {
+ $errors = ['amount' => \trans('validation.minamount')];
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
- $payment_request = [
- 'amount' => [
- 'currency' => 'CHF',
- // a number with two decimals is required
- 'value' => sprintf('%.2f', $amount / 100),
- ],
- 'customerId' => $customer_id,
- 'sequenceType' => 'recurring',
- 'description' => 'Kolab Now Recurring Payment',
- 'webhookUrl' => self::serviceUrl('/api/webhooks/payment/mollie'),
+ $request = [
+ 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'currency' => 'CHF',
+ 'amount' => $amount,
+ 'description' => \config('app.name') . ' Payment',
];
- // Create the payment in Mollie
- $payment = mollie()->payments()->create($payment_request);
+ $provider = PaymentProvider::factory();
+
+ $result = $provider->payment($wallet, $request);
- // Store the payment reference in database
- self::storePayment($payment, $wallet->id, $amount);
+ $result['status'] = 'success';
- return true;
+ return response()->json($result);
}
/**
- * Create self URL
+ * Update payment status (and balance).
*
- * @param string $route Route/Path
+ * @param string $provider Provider name
*
- * @return string Full URL
+ * @return \Illuminate\Http\Response The response
*/
- protected static function serviceUrl(string $route): string
+ public function webhook($provider)
{
- $url = \url($route);
-
- $app_url = trim(\config('app.url'), '/');
- $pub_url = trim(\config('app.public_url'), '/');
+ $code = 200;
- if ($pub_url != $app_url) {
- $url = str_replace($app_url, $pub_url, $url);
+ if ($provider = PaymentProvider::factory($provider)) {
+ $code = $provider->webhook();
}
- return $url;
+ return response($code < 400 ? 'Success' : 'Server error', $code);
}
/**
- * Create a payment record in DB
+ * Charge a wallet with a "recurring" payment.
+ *
+ * @param \App\Wallet $wallet The wallet to charge
+ * @param int $amount The amount of money in cents
*
- * @param object $payment Mollie payment
- * @param string $wallet_id Wallet ID
- * @param int $amount Amount of money in cents
+ * @return bool
*/
- protected static function storePayment($payment, $wallet_id, $amount): void
+ public static function directCharge(Wallet $wallet, $amount): bool
{
- $db_payment = new Payment();
- $db_payment->id = $payment->id;
- $db_payment->description = $payment->description;
- $db_payment->status = $payment->status;
- $db_payment->amount = $amount;
- $db_payment->wallet_id = $wallet_id;
- $db_payment->save();
+ $request = [
+ 'type' => PaymentProvider::TYPE_RECURRING,
+ 'currency' => 'CHF',
+ 'amount' => $amount,
+ 'description' => \config('app.name') . ' Recurring Payment',
+ ];
+
+ $provider = PaymentProvider::factory();
+
+ if ($result = $provider->payment($wallet, $request)) {
+ return true;
+ }
+
+ return false;
}
}
diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php
new file mode 100644
--- /dev/null
+++ b/src/app/Providers/Payment/Mollie.php
@@ -0,0 +1,316 @@
+<?php
+
+namespace App\Providers\Payment;
+
+use App\Payment;
+use App\Utils;
+use App\Wallet;
+
+class Mollie extends \App\Providers\PaymentProvider
+{
+ /**
+ * Create a new auto-payment mandate for a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data:
+ * - amount: Value in cents
+ * - currency: The operation currency
+ * - description: Operation desc.
+ *
+ * @return array Provider payment data:
+ * - id: Operation identifier
+ * - redirectUrl: the location to redirect to
+ */
+ public function createMandate(Wallet $wallet, array $payment): ?array
+ {
+ // Register the user in Mollie, if not yet done
+ $customer_id = self::mollieCustomerId($wallet);
+
+ $request = [
+ 'amount' => [
+ 'currency' => $payment['currency'],
+ 'value' => '0.00',
+ ],
+ 'customerId' => $customer_id,
+ 'sequenceType' => 'first',
+ 'description' => $payment['description'],
+ 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
+ 'redirectUrl' => \url('/wallet'),
+ 'locale' => 'en_US',
+ // 'method' => 'creditcard',
+ ];
+
+ // Create the payment in Mollie
+ $response = mollie()->payments()->create($request);
+
+ // Store the payment reference in database
+ // TODO: This is not really needed as the amount=0 for this
+ // operation, and we don't really need to track it.
+ $payment['status'] = $response->status;
+ $payment['id'] = $response->id;
+ $payment['amount'] = 0;
+
+ self::storePayment($payment, $wallet->id);
+
+ return [
+ 'id' => $response->id,
+ 'redirectUrl' => $response->getCheckoutUrl(),
+ ];
+ }
+
+ /**
+ * Revoke the auto-payment mandate for the wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return bool True on success, False on failure
+ */
+ public function deleteMandate(Wallet $wallet): bool
+ {
+ // Get the Mandate info
+ $mandate = self::mollieMandate($wallet);
+
+ // Revoke the mandate on Mollie
+ if ($mandate) {
+ $mandate->revoke();
+
+ $wallet->setSetting('mollie_mandate_id', null);
+ }
+
+ return true;
+ }
+
+ /**
+ * Get a auto-payment mandate for the wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return array|null Mandate information:
+ * - id: Mandate identifier
+ * - method: user-friendly payment method desc.
+ * - isPending: the process didn't complete yet
+ * - isValid: the mandate is valid
+ */
+ public function getMandate(Wallet $wallet): ?array
+ {
+ // Get the Mandate info
+ $mandate = self::mollieMandate($wallet);
+
+ if (empty($mandate)) {
+ return null;
+ }
+
+ $result = [
+ 'id' => $mandate->id,
+ 'isPending' => $mandate->isPending(),
+ 'isValid' => $mandate->isValid(),
+ ];
+
+ $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',
+ $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;
+ }
+
+ /**
+ * Create a new payment.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data:
+ * - amount: Value in cents
+ * - currency: The operation currency
+ * - type: oneoff/recurring
+ * - description: Operation desc.
+ *
+ * @return array Provider payment data:
+ * - id: Operation identifier
+ * - redirectUrl: the location to redirect to
+ */
+ public function payment(Wallet $wallet, array $payment): ?array
+ {
+ // Register the user in Mollie, if not yet done
+ $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',
+ ];
+
+ if ($payment['type'] == self::TYPE_RECURRING) {
+ // Check if there's a valid mandate
+ $mandate = self::getMandate($wallet);
+
+ if (empty($mandate)) {
+ return null;
+ }
+
+ $request['mandateId'] = $mandate['id'];
+ } else {
+ // required for non-recurring payments
+ $request['redirectUrl'] = \url('/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)
+ }
+
+ // Create the payment in Mollie
+ $response = mollie()->payments()->create($request);
+
+ // Store the payment reference in database
+ $payment['status'] = $response->status;
+ $payment['id'] = $response->id;
+
+ self::storePayment($payment, $wallet->id);
+
+ return [
+ 'id' => $payment['id'],
+ 'redirectUrl' => $response->getCheckoutUrl(),
+ ];
+ }
+
+ /**
+ * Update payment status (and balance).
+ *
+ * @return int HTTP response code
+ */
+ public function webhook(): int
+ {
+ $payment = Payment::find($_POST['id']);
+
+ if (empty($payment)) {
+ // Mollie recommends to return "200 OK" even if the payment does not exist
+ return 200;
+ }
+
+ // Get the payment details from Mollie
+ $mollie_payment = mollie()->payments()->get($payment->id);
+
+ if (empty($mollie_payment)) {
+ // Mollie recommends to return "200 OK" even if the payment does not exist
+ return 200;
+ }
+
+ if ($mollie_payment->isPaid()) {
+ if (!$mollie_payment->hasRefunds() && !$mollie_payment->hasChargebacks()) {
+ // 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);
+ }
+ } elseif ($mollie_payment->hasRefunds()) {
+ // The payment has been (partially) refunded.
+ // The status of the payment is still "paid"
+ // TODO: Update balance
+ } elseif ($mollie_payment->hasChargebacks()) {
+ // The payment has been (partially) charged back.
+ // The status of the payment is still "paid"
+ // TODO: Update balance
+ }
+ }
+
+ // 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) {
+ $payment->status = $mollie_payment->status;
+ $payment->save();
+ }
+
+ return 200;
+ }
+
+ /**
+ * Get Mollie customer identifier for specified wallet.
+ * Create one if does not exist yet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return string Mollie customer identifier
+ */
+ protected static function mollieCustomerId(Wallet $wallet): string
+ {
+ $customer_id = $wallet->getSetting('mollie_id');
+
+ // Register the user in Mollie
+ if (empty($customer_id)) {
+ $customer = mollie()->customers()->create([
+ 'name' => $wallet->owner->name(),
+ 'email' => $wallet->id . '@private.' . \config('app.domain'),
+ ]);
+
+ $customer_id = $customer->id;
+
+ $wallet->setSetting('mollie_id', $customer->id);
+ }
+
+ return $customer_id;
+ }
+
+ /**
+ * Get the active Mollie auto-payment mandate
+ */
+ protected static function mollieMandate(Wallet $wallet)
+ {
+ $customer_id = self::mollieCustomerId($wallet);
+
+ $customer = mollie()->customers()->get($customer_id);
+
+ // Get the manadate reference we already have
+ if ($mandate_id = $wallet->getSetting('mollie_mandate_id')) {
+ $mandate = $customer->getMandate($mandate_id);
+ if ($mandate && ($mandate->isValid() || $mandate->isPending())) {
+ return $mandate;
+ }
+ }
+
+ // Get all mandates from Mollie and find the active one
+ foreach ($customer->mandates() as $mandate) {
+ if ($mandate->isValid() || $mandate->isPending()) {
+ $wallet->setSetting('mollie_mandate_id', $mandate->id);
+ return $mandate;
+ }
+ }
+ }
+}
diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php
new file mode 100644
--- /dev/null
+++ b/src/app/Providers/Payment/Stripe.php
@@ -0,0 +1,334 @@
+<?php
+
+namespace App\Providers\Payment;
+
+use App\Payment;
+use App\Utils;
+use App\Wallet;
+use App\WalletSetting;
+use Stripe as StripeAPI;
+
+class Stripe extends \App\Providers\PaymentProvider
+{
+ /**
+ * Class constructor.
+ */
+ public function __construct()
+ {
+ StripeAPI\Stripe::setApiKey(\env('STRIPE_KEY'));
+ }
+
+ /**
+ * Create a new auto-payment mandate for a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data:
+ * - amount: Value in cents
+ * - currency: The operation currency
+ * - description: Operation desc.
+ *
+ * @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);
+
+ $request = [
+ 'customer' => $customer_id,
+ 'cancel_url' => \url('/wallet'), // required
+ 'success_url' => \url('/wallet'), // required
+ 'payment_method_types' => ['card'], // required
+ 'locale' => 'en',
+ 'mode' => 'setup',
+ ];
+
+ $session = StripeAPI\Checkout\Session::create($request);
+
+ 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',
+ ];
+
+ switch ($pm->type) {
+ case 'card':
+ // TODO: card number
+ $result['method'] = \sprintf(
+ '%s (**** **** **** %s)',
+ \ucfirst($pm->card->brand) ?: 'Card',
+ $pm->card->last4
+ );
+
+ break;
+
+ default:
+ $result['method'] = 'Unknown method';
+ }
+
+ return $result;
+ }
+
+ /**
+ * Create a new payment.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data:
+ * - amount: Value in cents
+ * - currency: The operation currency
+ * - type: first/oneoff/recurring
+ * - description: Operation desc.
+ *
+ * @return array Provider payment/session data:
+ * - id: Session identifier
+ */
+ public function payment(Wallet $wallet, array $payment): ?array
+ {
+ if ($payment['type'] == self::TYPE_RECURRING) {
+ return $this->paymentRecurring($wallet, $payment);
+ }
+
+ // Register the user in Stripe, if not yet done
+ $customer_id = self::stripeCustomerId($wallet);
+
+ $request = [
+ 'customer' => $customer_id,
+ 'cancel_url' => \url('/wallet'), // required
+ 'success_url' => \url('/wallet'), // required
+ 'payment_method_types' => ['card'], // required
+ 'locale' => 'en',
+ 'line_items' => [
+ [
+ 'name' => $payment['description'],
+ 'amount' => $payment['amount'],
+ 'currency' => \strtolower($payment['currency']),
+ 'quantity' => 1,
+ ]
+ ]
+ ];
+
+ $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);
+
+ 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;
+ }
+
+ $request = [
+ '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,
+ ];
+
+ $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);
+
+ return [
+ 'id' => $payment['id'],
+ ];
+ }
+
+ /**
+ * Update payment status (and balance).
+ *
+ * @return int HTTP response code
+ */
+ public function webhook(): int
+ {
+ $payload = file_get_contents('php://input');
+ $sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'];
+
+ // Parse and validate the input
+ try {
+ $event = StripeAPI\Webhook::constructEvent(
+ $payload,
+ $sig_header,
+ \env('STRIPE_WEBHOOK_SECRET')
+ );
+ } catch (\UnexpectedValueException $e) {
+ // 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;
+ $payment = Payment::find($intent->id);
+
+ switch ($intent->status) {
+ case StripeAPI\PaymentIntent::STATUS_CANCELED:
+ $status = self::STATUS_CANCELED;
+ break;
+ case StripeAPI\PaymentIntent::STATUS_SUCCEEDED:
+ $status = self::STATUS_PAID;
+ break;
+ default:
+ $status = self::STATUS_PENDING;
+ }
+
+ if ($status == self::STATUS_PAID) {
+ // Update the balance, if it wasn't already
+ if ($payment->status != self::STATUS_PAID) {
+ $payment->wallet->credit($payment->amount);
+ }
+ }
+
+ if ($payment->status != self::STATUS_PAID) {
+ $payment->status = $status;
+ $payment->save();
+ }
+
+ break;
+
+ case StripeAPI\Event::SETUP_INTENT_SUCCEEDED:
+ $intent = $event->data->object;
+
+ // 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 ($setting) {
+ $setting->wallet->setSetting('stripe_mandate_id', $intent->id);
+ }
+
+ break;
+ }
+
+ return 200;
+ }
+
+ /**
+ * Get Stripe customer identifier for specified wallet.
+ * Create one if does not exist yet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return string Stripe customer identifier
+ */
+ protected static function stripeCustomerId(Wallet $wallet): string
+ {
+ $customer_id = $wallet->getSetting('stripe_id');
+
+ // Register the user in Stripe
+ if (empty($customer_id)) {
+ $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're not use these
+
+ if ($mandate_id = $wallet->getSetting('stripe_mandate_id')) {
+ $mandate = StripeAPI\SetupIntent::retrieve($mandate_id);
+ if ($mandate && $mandate->status != 'canceled') {
+ return $mandate;
+ }
+ }
+ }
+}
diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php
new file mode 100644
--- /dev/null
+++ b/src/app/Providers/PaymentProvider.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace App\Providers;
+
+use App\Payment;
+use App\Wallet;
+
+abstract class PaymentProvider
+{
+ public const STATUS_OPEN = 'open';
+ public const STATUS_CANCELED = 'canceled';
+ public const STATUS_PENDING = 'pending';
+ public const STATUS_AUTHORIZED = 'authorized';
+ public const STATUS_EXPIRED = 'expired';
+ public const STATUS_FAILED = 'failed';
+ public const STATUS_PAID = 'paid';
+
+ public const TYPE_ONEOFF = 'oneoff';
+ public const TYPE_RECURRING = 'recurring';
+
+ /** const int Minimum amount of money in a single payment (in cents) */
+ public const MIN_AMOUNT = 1000;
+
+ /**
+ * Factory method
+ */
+ public static function factory(string $provider = null)
+ {
+ if (!$provider) {
+ $provider = \env('PAYMENT_PROVIDER') ?: 'mollie';
+ }
+
+ switch ($provider) {
+ case 'stripe':
+ return new \App\Providers\Payment\Stripe();
+
+ case 'mollie':
+ return new \App\Providers\Payment\Mollie();
+
+ default:
+ throw new \Exception("Invalid payment provider: {$provider}");
+ }
+ }
+
+ /**
+ * Create a new auto-payment mandate for a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data:
+ * - amount: Value in cents
+ * - currency: The operation currency
+ * - description: Operation desc.
+ *
+ * @return array Provider payment data:
+ * - id: Operation identifier
+ * - redirectUrl: the location to redirect to
+ */
+ abstract public function createMandate(Wallet $wallet, array $payment): ?array;
+
+ /**
+ * Revoke the auto-payment mandate for a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return bool True on success, False on failure
+ */
+ abstract public function deleteMandate(Wallet $wallet): bool;
+
+ /**
+ * Get a auto-payment mandate for a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return array|null Mandate information:
+ * - id: Mandate identifier
+ * - method: user-friendly payment method desc.
+ * - isPending: the process didn't complete yet
+ * - isValid: the mandate is valid
+ */
+ abstract public function getMandate(Wallet $wallet): ?array;
+
+ /**
+ * Create a new payment.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data:
+ * - amount: Value in cents
+ * - currency: The operation currency
+ * - type: first/oneoff/recurring
+ * - description: Operation description
+ *
+ * @return array Provider payment/session data:
+ * - id: Operation identifier
+ * - redirectUrl
+ */
+ abstract public function payment(Wallet $wallet, array $payment): ?array;
+
+ /**
+ * Update payment status (and balance).
+ *
+ * @return int HTTP response code
+ */
+ abstract public function webhook(): int;
+
+ /**
+ * Create a payment record in DB
+ *
+ * @param array $payment Payment information
+ * @param string $wallet_id Wallet ID
+ */
+ protected static function storePayment(array $payment, $wallet_id): void
+ {
+ $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->wallet_id = $wallet_id;
+ $db_payment->save();
+ }
+}
diff --git a/src/app/Traits/UserSettingsTrait.php b/src/app/Traits/SettingsTrait.php
rename from src/app/Traits/UserSettingsTrait.php
rename to src/app/Traits/SettingsTrait.php
--- a/src/app/Traits/UserSettingsTrait.php
+++ b/src/app/Traits/SettingsTrait.php
@@ -2,10 +2,9 @@
namespace App\Traits;
-use App\UserSetting;
use Illuminate\Support\Facades\Cache;
-trait UserSettingsTrait
+trait SettingsTrait
{
/**
* Obtain the value for a setting.
@@ -17,9 +16,9 @@
* $locale = $user->getSetting('locale');
* ```
*
- * @param string $key Lookup key
+ * @param string $key Setting name
*
- * @return string|null
+ * @return string|null Setting value
*/
public function getSetting(string $key)
{
@@ -34,6 +33,25 @@
return empty($value) ? null : $value;
}
+ /**
+ * Remove a setting.
+ *
+ * Example Usage:
+ *
+ * ```php
+ * $user = User::firstOrCreate(['email' => 'some@other.erg']);
+ * $user->removeSetting('locale');
+ * ```
+ *
+ * @param string $key Setting name
+ *
+ * @return void
+ */
+ public function removeSetting(string $key): void
+ {
+ $this->setSetting($key, null);
+ }
+
/**
* Create or update a setting.
*
@@ -49,7 +67,7 @@
*
* @return void
*/
- public function setSetting(string $key, $value)
+ public function setSetting(string $key, $value): void
{
$this->storeSetting($key, $value);
$this->setCache();
@@ -69,7 +87,7 @@
*
* @return void
*/
- public function setSettings(array $data = [])
+ public function setSettings(array $data = []): void
{
foreach ($data as $key => $value) {
$this->storeSetting($key, $value);
@@ -81,12 +99,13 @@
private function storeSetting(string $key, $value): void
{
if ($value === null || $value === '') {
- if ($setting = UserSetting::where(['user_id' => $this->id, 'key' => $key])->first()) {
+ // Note: We're selecting the record first, so observers can act
+ if ($setting = $this->settings()->where('key', $key)->first()) {
$setting->delete();
}
} else {
- UserSetting::updateOrCreate(
- ['user_id' => $this->id, 'key' => $key],
+ $this->settings()->updateOrCreate(
+ ['key' => $key],
['value' => $value]
);
}
@@ -94,8 +113,10 @@
private function getCache()
{
- if (Cache::has('user_settings_' . $this->id)) {
- return Cache::get('user_settings_' . $this->id);
+ $model = \strtolower(get_class($this));
+
+ if (Cache::has("{$model}_settings_{$this->id}")) {
+ return Cache::get("{$model}_settings_{$this->id}");
}
return $this->setCache();
@@ -103,8 +124,10 @@
private function setCache()
{
- if (Cache::has('user_settings_' . $this->id)) {
- Cache::forget('user_settings_' . $this->id);
+ $model = \strtolower(get_class($this));
+
+ if (Cache::has("{$model}_settings_{$this->id}")) {
+ Cache::forget("{$model}_settings_{$this->id}");
}
$cached = [];
@@ -114,7 +137,7 @@
}
}
- Cache::forever('user_settings_' . $this->id, $cached);
+ Cache::forever("{$model}_settings_{$this->id}", $cached);
return $this->getCache();
}
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -5,7 +5,7 @@
use App\Entitlement;
use App\UserAlias;
use App\Traits\UserAliasesTrait;
-use App\Traits\UserSettingsTrait;
+use App\Traits\SettingsTrait;
use App\Wallet;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -26,7 +26,7 @@
use Notifiable;
use NullableFields;
use UserAliasesTrait;
- use UserSettingsTrait;
+ use SettingsTrait;
use SoftDeletes;
// a new user, default on creation
diff --git a/src/app/Utils.php b/src/app/Utils.php
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -98,9 +98,36 @@
$isAdmin = strpos(request()->getHttpHost(), 'admin.') === 0;
$env['jsapp'] = $isAdmin ? 'admin.js' : 'user.js';
+ $env['paymentProvider'] = \env('PAYMENT_PROVIDER');
+
+ if ($env['paymentProvider'] == 'stripe') {
+ $env['stripePK'] = \env('STRIPE_PUBLIC_KEY');
+ }
+
return $env;
}
+ /**
+ * Create self URL
+ *
+ * @param string $route Route/Path
+ *
+ * @return string Full URL
+ */
+ public static function serviceUrl(string $route): string
+ {
+ $url = \url($route);
+
+ $app_url = trim(\config('app.url'), '/');
+ $pub_url = trim(\config('app.public_url'), '/');
+
+ if ($pub_url != $app_url) {
+ $url = str_replace($app_url, $pub_url, $url);
+ }
+
+ return $url;
+ }
+
/**
* Email address (login or alias) validation
*
diff --git a/src/app/Wallet.php b/src/app/Wallet.php
--- a/src/app/Wallet.php
+++ b/src/app/Wallet.php
@@ -3,6 +3,7 @@
namespace App;
use App\User;
+use App\Traits\SettingsTrait;
use Carbon\Carbon;
use Iatstuti\Database\Support\NullableFields;
use Illuminate\Database\Eloquent\Model;
@@ -17,6 +18,7 @@
class Wallet extends Model
{
use NullableFields;
+ use SettingsTrait;
public $incrementing = false;
protected $keyType = 'string';
@@ -208,4 +210,14 @@
{
return $this->hasMany('App\Payment');
}
+
+ /**
+ * Any (additional) properties of this wallet.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function settings()
+ {
+ return $this->hasMany('App\WalletSetting');
+ }
}
diff --git a/src/app/WalletSetting.php b/src/app/WalletSetting.php
new file mode 100644
--- /dev/null
+++ b/src/app/WalletSetting.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * A collection of settings for a Wallet.
+ *
+ * @property int $id
+ * @property string $wallet_id
+ * @property string $key
+ * @property string $value
+ */
+class WalletSetting extends Model
+{
+ protected $fillable = [
+ 'wallet_id', 'key', 'value'
+ ];
+
+ /**
+ * The wallet to which this setting belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function wallet()
+ {
+ return $this->belongsTo(
+ '\App\Wallet',
+ 'wallet_id', /* local */
+ 'id' /* remote */
+ );
+ }
+}
diff --git a/src/composer.json b/src/composer.json
--- a/src/composer.json
+++ b/src/composer.json
@@ -27,6 +27,7 @@
"silviolleite/laravelpwa": "^1.0",
"spatie/laravel-translatable": "^4.2",
"spomky-labs/otphp": "~4.0.0",
+ "stripe/stripe-php": "^7.29",
"swooletw/laravel-swoole": "^2.6",
"torann/currency": "^1.0",
"torann/geoip": "^1.0",
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
@@ -16,7 +16,7 @@
Schema::create(
'payments',
function (Blueprint $table) {
- $table->string('id', 16)->primary();
+ $table->string('id', 32)->primary();
$table->string('wallet_id', 36);
$table->string('status', 16);
$table->integer('amount');
diff --git a/src/database/migrations/2020_03_16_100000_create_payments.php b/src/database/migrations/2020_04_21_100000_create_wallet_settings.php
copy from src/database/migrations/2020_03_16_100000_create_payments.php
copy to src/database/migrations/2020_04_21_100000_create_wallet_settings.php
--- a/src/database/migrations/2020_03_16_100000_create_payments.php
+++ b/src/database/migrations/2020_04_21_100000_create_wallet_settings.php
@@ -4,7 +4,7 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
-class CreatePayments extends Migration
+class CreateWalletSettings extends Migration
{
/**
* Run the migrations.
@@ -14,16 +14,16 @@
public function up()
{
Schema::create(
- 'payments',
+ 'wallet_settings',
function (Blueprint $table) {
- $table->string('id', 16)->primary();
- $table->string('wallet_id', 36);
- $table->string('status', 16);
- $table->integer('amount');
- $table->text('description');
+ $table->bigIncrements('id');
+ $table->string('wallet_id');
+ $table->string('key');
+ $table->string('value');
$table->timestamps();
$table->foreign('wallet_id')->references('id')->on('wallets')->onDelete('cascade');
+ $table->unique(['wallet_id', 'key']);
}
);
}
@@ -35,6 +35,6 @@
*/
public function down()
{
- Schema::dropIfExists('payments');
+ Schema::dropIfExists('wallet_settings');
}
}
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -10,6 +10,8 @@
import MenuComponent from '../vue/Widgets/Menu'
import store from './store'
+const loader = '<div class="app-loader"><div class="spinner-border" role="status"><span class="sr-only">Loading</span></div></div>'
+
const app = new Vue({
el: '#app',
components: {
@@ -67,13 +69,20 @@
delete axios.defaults.headers.common.Authorization
this.$router.push({ name: 'login' })
},
- // Display "loading" overlay (to be used by route components)
+ // Display "loading" overlay inside of the specified element
+ addLoader(elem) {
+ $(elem).css({position: 'relative'}).append($(loader).addClass('small'))
+ },
+ // Remove loader element added in addLoader()
+ removeLoader(elem) {
+ $(elem).find('.app-loader').remove()
+ },
startLoading() {
this.isLoading = true
// Lock the UI with the 'loading...' element
let loading = $('#app > .app-loader').show()
if (!loading.length) {
- $('#app').append($('<div class="app-loader"><div class="spinner-border" role="status"><span class="sr-only">Loading</span></div></div>'))
+ $('#app').append($(loader))
}
},
// Hide "loading" overlay
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -10,6 +10,8 @@
| The following language lines are used in the application.
*/
+ 'mandate-delete-success' => 'The auto-payment has been removed.',
+
'planbutton' => 'Choose :plan',
'process-user-new' => 'Registering a user...',
diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss
--- a/src/resources/sass/app.scss
+++ b/src/resources/sass/app.scss
@@ -83,6 +83,12 @@
border-width: 15px;
color: #b2aa99;
}
+
+ &.small .spinner-border {
+ width: 25px;
+ height: 25px;
+ border-width: 3px;
+ }
}
pre {
@@ -212,3 +218,24 @@
margin-bottom: 0;
}
}
+
+.form-separator {
+ position: relative;
+ margin: 1em 0;
+ display: flex;
+ justify-content: center;
+
+ hr {
+ border-color: #999;
+ margin: 0;
+ position: absolute;
+ top: .75em;
+ width: 100%;
+ }
+
+ span {
+ background: #fff;
+ padding: 0 1em;
+ z-index: 1;
+ }
+}
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
@@ -7,7 +7,96 @@
<p>Current account balance is
<span :class="balance < 0 ? 'text-danger' : 'text-success'"><strong>{{ $root.price(balance) }}</strong></span>
</p>
- <button type="button" class="btn btn-primary" @click="payment()">Add 10 bucks to my wallet</button>
+ <button type="button" class="btn btn-primary" @click="paymentDialog()">Add credit</button>
+ </div>
+ </div>
+ </div>
+
+ <div id="payment-dialog" class="modal" tabindex="-1" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">{{ paymentDialogTitle }}</h5>
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <span aria-hidden="true">×</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <div id="payment" v-if="paymentForm == 'init'">
+ <p>Choose the amount by which you want to top up your wallet.</p>
+ <form id="payment-form" @submit.prevent="payment">
+ <div class="form-group">
+ <div class="input-group">
+ <input type="text" class="form-control" id="amount" v-model="amount" required>
+ <span class="input-group-append">
+ <span class="input-group-text">{{ wallet_currency }}</span>
+ </span>
+ </div>
+ </div>
+ <div class="w-100 text-center">
+ <button type="submit" class="btn btn-primary">Continue</button>
+ </div>
+ </form>
+ <div class="form-separator"><hr><span>or</span></div>
+ <div id="mandate-form" v-if="!mandate.id">
+ <p>Add auto-payment, so you never run out.</p>
+ <div class="w-100 text-center">
+ <button type="button" class="btn btn-primary" @click="autoPaymentForm">Set up auto-payment</button>
+ </div>
+ </div>
+ <div id="mandate-info" v-if="mandate.id">
+ <p>Auto-payment is set to fill up your account by <b>{{ mandate.amount }} CHF</b>
+ every time your account balance gets under <b>{{ mandate.balance }} CHF</b>.
+ You will be charged via {{ mandate.method }}.
+ </p>
+ <p>You can cancel or change the auto-payment at any time.</p>
+ <div class="form-group d-flex justify-content-around">
+ <button type="button" class="btn btn-danger" @click="autoPaymentDelete">Cancel auto-payment</button>
+ <button type="button" class="btn btn-primary" @click="autoPaymentChange">Change auto-payment</button>
+ </div>
+ </div>
+ </div>
+ <div id="auto-payment" v-if="paymentForm == 'auto'">
+ <form id="payment-form" @submit.prevent="autoPayment">
+ <p>Here is how it works: Every time your account runs low,
+ we will charge your preferred payment method for an amount you choose.
+ You can cancel or change the auto-payment option at any time.
+ </p>
+ <div class="form-group row">
+ <label for="mandate_amount" class="col-sm-6 col-form-label">Fill up by</label>
+ <div class="input-group col-sm-6">
+ <input type="text" class="form-control" id="mandate_amount" v-model="mandate.amount" required>
+ <span class="input-group-append">
+ <span class="input-group-text">{{ wallet_currency }}</span>
+ </span>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="mandate_balance" class="col-sm-6 col-form-label">when account balance is below</label>
+ <div class="col-sm-6">
+ <div class="input-group">
+ <input type="text" class="form-control" id="mandate_balance" v-model="mandate.balance" required>
+ <span class="input-group-append">
+ <span class="input-group-text">{{ wallet_currency }}</span>
+ </span>
+ </div>
+ </div>
+ </div>
+ <p v-if="!mandate.id">
+ Next, you will be redirected to the checkout page, where you can provide
+ your credit card details.
+ </p>
+ <div class="w-100 text-center">
+ <button type="submit" class="btn btn-primary">
+ {{ mandate.id ? 'Submit' : 'Continue' }}
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
+ </div>
</div>
</div>
</div>
@@ -18,7 +107,13 @@
export default {
data() {
return {
+ amount: '',
balance: 0,
+ mandate: { amount: 10, balance: 0 },
+ paymentDialogTitle: null,
+ paymentForm: 'init',
+ stripe: null,
+ wallet_currency: 'CHF'
}
},
mounted() {
@@ -27,15 +122,117 @@
this.$store.state.authInfo.wallets.forEach(wallet => {
this.balance += wallet.balance
})
+
+ if (window.config.stripePK) {
+ this.stripeInit()
+ }
},
methods: {
+ paymentDialog() {
+ const dialog = $('#payment-dialog')
+ const mandate_form = $('#mandate-form')
+
+ this.$root.removeLoader(mandate_form)
+
+ if (!this.mandate.id) {
+ this.$root.addLoader(mandate_form)
+ axios.get('/api/v4/payments/mandate')
+ .then(response => {
+ this.$root.removeLoader(mandate_form)
+ this.mandate = response.data
+ })
+ .catch(error => {
+ this.$root.removeLoader(mandate_form)
+ })
+ }
+
+ this.paymentForm = 'init'
+ this.paymentDialogTitle = 'Top up your wallet'
+
+ this.dialog = dialog.on('shown.bs.modal', () => {
+ dialog.find('#amount').focus()
+ }).modal()
+ },
payment() {
- axios.post('/api/v4/payments', {amount: 1000})
+ axios.post('/api/v4/payments', {amount: this.amount})
+ .then(response => {
+ if (response.data.redirectUrl) {
+ location.href = response.data.redirectUrl
+ } else {
+ this.stripeCheckout(response.data)
+ }
+ })
+ },
+ autoPayment() {
+ const method = this.mandate.id ? 'put' : 'post'
+ const post = {
+ mandate_amount: this.mandate.amount,
+ mandate_balance: this.mandate.balance
+ }
+
+ axios[method]('/api/v4/payments/mandate', post)
.then(response => {
if (response.data.redirectUrl) {
location.href = response.data.redirectUrl
+ } else if (response.data.id) {
+ this.stripeCheckout(response.data)
+ } else {
+ this.dialog.modal('hide')
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ }
+ }
+ })
+ },
+ autoPaymentChange(event) {
+ this.autoPaymentForm(event, 'Update auto-payment')
+ },
+ autoPaymentDelete() {
+ axios.delete('/api/v4/payments/mandate')
+ .then(response => {
+ this.mandate = { amount: 10, balance: 0 }
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
}
})
+ },
+ autoPaymentForm(event, title) {
+ this.paymentForm = 'auto'
+ this.paymentDialogTitle = title || 'Add auto-payment'
+ this.dialog.find('#mandate_amount').focus()
+ },
+ stripeInit() {
+ let script = $('#stripe-script')
+
+ if (!script.length) {
+ script = document.createElement('script')
+
+ script.onload = () => {
+ this.stripe = Stripe(window.config.stripePK)
+ }
+
+ script.id = 'stripe-script'
+ script.src = 'https://js.stripe.com/v3/'
+
+ document.getElementsByTagName('head')[0].appendChild(script)
+ } else {
+ this.stripe = Stripe(window.config.stripePK)
+ }
+ },
+ stripeCheckout(data) {
+ if (!this.stripe) {
+ return
+ }
+
+ this.stripe.redirectToCheckout({
+ sessionId: data.id
+ }).then(result => {
+ // If it fails due to a browser or network error,
+ // display the localized error message to the user
+ if (result.error) {
+ this.$toast.error(result.error.message)
+ }
+ })
}
}
}
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -70,6 +70,10 @@
Route::apiResource('wallets', API\V4\WalletsController::class);
Route::post('payments', 'API\V4\PaymentsController@store');
+ Route::get('payments/mandate', 'API\V4\PaymentsController@mandate');
+ Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate');
+ Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate');
+ Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete');
}
);
@@ -78,7 +82,7 @@
'domain' => \config('app.domain'),
],
function () {
- Route::post('webhooks/payment/mollie', 'API\V4\PaymentsController@webhook');
+ Route::post('webhooks/payment/{provider}', 'API\V4\PaymentsController@webhook');
}
);
diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php
--- a/src/tests/Feature/Backends/LDAPTest.php
+++ b/src/tests/Feature/Backends/LDAPTest.php
@@ -108,7 +108,6 @@
$expected['inetuserstatus'] = $user->status;
$expected['mailquota'] = 2097152;
$expected['nsroledn'] = null;
- // TODO: country? dn
$ldap_user = LDAP::getUser($user->email);
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sun, Apr 5, 4:47 AM (16 h, 35 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18832369
Default Alt Text
D1231.1775364479.diff (68 KB)
Attached To
Mode
D1231: Custom payments, auto-payments, Stripe support
Attached
Detach File
Event Timeline