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 @@ -52,6 +52,10 @@ ); $wallet->chargeEntitlements(); + + if ($wallet->balance < 0) { + \App\Jobs\WalletPayment::dispatch($wallet); + } } } } diff --git a/src/app/Http/Controllers/API/PaymentsController.php b/src/app/Http/Controllers/API/PaymentsController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/PaymentsController.php @@ -0,0 +1,207 @@ +middleware('auth:api', ['except' => ['webhook']]); + } + + /** + * Create a new payment. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function store(Request $request) + { + $current_user = Auth::guard()->user(); + + // TODO: Wallet selection + $wallet = $current_user->wallets()->first(); + + // Check required fields + $v = Validator::make( + $request->all(), + [ + 'amount' => 'required|int|min:1', + ] + ); + + 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. @) 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); + } + + $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' => url('/api/v4/payments/webhook'), + // 'locale' => 'en_US', + ]; + + // Create the payment in Mollie + $payment = mollie()->payments()->create($payment_request); + + // Store the payment reference in database + self::storePayment($payment, $wallet->id, $request->amount); + + return response()->json([ + 'status' => 'success', + 'redirectUrl' => $payment->getCheckoutUrl(), + ]); + } + + /** + * Update payment status (and balance). + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\Response The response + */ + public function webhook(Request $request, $id) + { + $db_payment = Payment::find($request->id); + + // Mollie recommends to return "200 OK" even if the payment does not exist + if (empty($db_payment)) { + return response('Success', 200); + } + + // Get the payment details from Mollie + $payment = mollie()->payments->get($request->id); + + if (empty($payment)) { + return response('Success', 200); + } + + 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 + } + } + + $db_payment->status = $payment->status; + $db_payment->save(); + + return response('Success', 200); + } + + /** + * Charge 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 + */ + public static function directCharge(Wallet $wallet, $amount): bool + { + $customer_id = $wallet->owner->getSetting('mollie_id'); + + if (empty($customer_id)) { + return false; + } + + // Check if there's at least one valid mandate + $mandates = mollie()->mandates()->listFor($customer_id)->filter(function ($mandate) { + return $mandate->isValid(); + }); + + if (empty($mandates)) { + return false; + } + + $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' => url('/api/v4/payments/webhook'), + ]; + + // Create the payment in Mollie + $payment = mollie()->payments()->create($payment_request); + + // Store the payment reference in database + self::storePayment($payment, $wallet->id, $amount); + + return true; + } + + /** + * Create a payment record in DB + * + * @param object $payment Mollie payment + * @param string $wallet_id Wallet ID + * @param int $amount Amount of money in cents + */ + protected static function storePayment($payment, $wallet_id, $amount): void + { + $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(); + } +} diff --git a/src/app/Jobs/WalletPayment.php b/src/app/Jobs/WalletPayment.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/WalletPayment.php @@ -0,0 +1,51 @@ +wallet = $wallet; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + if (!$this->wallet->balance < 0) { + PaymentsController::directCharge($this->wallet, $this->wallet->balance * -1); + } + } +} diff --git a/src/app/Observers/WalletObserver.php b/src/app/Observers/WalletObserver.php --- a/src/app/Observers/WalletObserver.php +++ b/src/app/Observers/WalletObserver.php @@ -60,5 +60,10 @@ if ($wallet->entitlements()->count() > 0) { return false; } + + // can't remove a wallet that has payments attached. + if ($wallet->payments()->count() > 0) { + return false; + } } } diff --git a/src/app/Payment.php b/src/app/Payment.php new file mode 100644 --- /dev/null +++ b/src/app/Payment.php @@ -0,0 +1,37 @@ + 'integer' + ]; + + /** + * The wallet to which this payment belongs. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function wallet() + { + return $this->belongsTo( + '\App\Wallet', + 'wallet_id', /* local */ + 'id' /* remote */ + ); + } +} diff --git a/src/app/Wallet.php b/src/app/Wallet.php --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -93,7 +93,6 @@ return $charges; } - /** * Calculate the expected charges to this wallet. * @@ -121,11 +120,11 @@ /** * Add an amount of pecunia to this wallet's balance. * - * @param float $amount The amount of pecunia to add. + * @param int $amount The amount of pecunia to add (in cents). * - * @return Wallet + * @return Wallet Self */ - public function credit(float $amount) + public function credit(int $amount): Wallet { $this->balance += $amount; @@ -137,11 +136,11 @@ /** * Deduct an amount of pecunia from this wallet's balance. * - * @param float $amount The amount of pecunia to deduct. + * @param int $amount The amount of pecunia to deduct (in cents). * - * @return Wallet + * @return Wallet Self */ - public function debit(float $amount) + public function debit(int $amount): Wallet { $this->balance -= $amount; @@ -184,4 +183,14 @@ { return $this->belongsTo('App\User', 'user_id', 'id'); } + + /** + * Payments on this wallet. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function payments() + { + return $this->hasMany('App\Payment'); + } } diff --git a/src/composer.json b/src/composer.json --- a/src/composer.json +++ b/src/composer.json @@ -22,6 +22,7 @@ "kolab/net_ldap3": "dev-master", "laravel/framework": "6.*", "laravel/tinker": "^1.0", + "mollie/laravel-mollie": "^2.9", "morrislaptop/laravel-queue-clear": "^1.2", "silviolleite/laravelpwa": "^1.0", "spatie/laravel-translatable": "^4.2", diff --git a/src/database/migrations/2020_03_16_100000_create_payments.php b/src/database/migrations/2020_03_16_100000_create_payments.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2020_03_16_100000_create_payments.php @@ -0,0 +1,40 @@ +string('id', 16)->primary(); + $table->string('wallet_id', 36); + $table->string('status', 16); + $table->integer('amount'); + $table->text('description'); + $table->timestamps(); + + $table->foreign('wallet_id')->references('id')->on('wallets')->onDelete('cascade'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('payments'); + } +} 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 @@ -160,8 +160,8 @@ .badge { position: absolute; - top: .5rem; - right: .5rem; + top: 0.5rem; + right: 0.5rem; } } 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,6 +7,7 @@

Current account balance is {{ $root.price(balance) }}

+ @@ -28,6 +29,14 @@ }) }, methods: { + payment() { + axios.post('/api/v4/payments', {amount: 1000}) + .then(response => { + if (response.data.url) { + location.href = response.data.redirectUrl + } + }) + } } } diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -49,5 +49,8 @@ Route::apiResource('skus', API\SkusController::class); Route::apiResource('users', API\UsersController::class); Route::apiResource('wallets', API\WalletsController::class); + + Route::post('payments', 'API\PaymentsController@store'); + Route::post('payments/webhook', 'API\PaymentsController@webhook'); } );