diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php index 62f971cb..062f38cd 100644 --- a/src/app/Http/Controllers/API/V4/PaymentsController.php +++ b/src/app/Http/Controllers/API/V4/PaymentsController.php @@ -1,609 +1,610 @@ guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $mandate = self::walletMandate($wallet); return response()->json($mandate); } /** * Create a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateCreate(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); // Input validation if ($errors = self::mandateValidate($request, $wallet)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $wallet->setSettings([ 'mandate_amount' => $request->amount, 'mandate_balance' => $request->balance, ]); $mandate = [ 'currency' => $wallet->currency, 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' ' . self::trans('app.mandate-description-suffix'), 'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD, ]; // Normally the auto-payment setup operation is 0, if the balance is below the threshold // we'll top-up the wallet with the configured auto-payment amount if ($wallet->balance < round($request->balance * 100)) { $mandate['amount'] = (int) round($request->amount * 100); self::addTax($wallet, $mandate); } $provider = PaymentProvider::factory($wallet); $result = $provider->createMandate($wallet, $mandate); $result['status'] = 'success'; return response()->json($result); } /** * Revoke the auto-payment mandate. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateDelete() { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $provider = PaymentProvider::factory($wallet); $provider->deleteMandate($wallet); $wallet->setSetting('mandate_disabled', null); return response()->json([ 'status' => 'success', 'message' => self::trans('app.mandate-delete-success'), ]); } /** * Update a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateUpdate(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); // Input validation if ($errors = self::mandateValidate($request, $wallet)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $wallet->setSettings([ 'mandate_amount' => $request->amount, 'mandate_balance' => $request->balance, // Re-enable the mandate to give it a chance to charge again // after it has been disabled (e.g. because the mandate amount was too small) 'mandate_disabled' => null, ]); // Trigger auto-payment if the balance is below the threshold if ($wallet->balance < round($request->balance * 100)) { \App\Jobs\WalletCharge::dispatch($wallet->id); } $result = self::walletMandate($wallet); $result['status'] = 'success'; $result['message'] = self::trans('app.mandate-update-success'); return response()->json($result); } /** * Reset the auto-payment mandate, create a new payment for it. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateReset(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $mandate = [ 'currency' => $wallet->currency, 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' ' . self::trans('app.mandate-description-suffix'), 'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD, 'redirectUrl' => \App\Utils::serviceUrl('/payment/status', $user->tenant_id), ]; $provider = PaymentProvider::factory($wallet); $result = $provider->createMandate($wallet, $mandate); $result['status'] = 'success'; return response()->json($result); } /** * Validate an auto-payment mandate request. * * @param \Illuminate\Http\Request $request The API request. * @param \App\Wallet $wallet The wallet * * @return array|null List of errors on error or Null on success */ protected static function mandateValidate(Request $request, Wallet $wallet) { $rules = [ 'amount' => 'required|numeric', 'balance' => 'required|numeric|min:0', ]; // Check required fields $v = Validator::make($request->all(), $rules); // TODO: allow comma as a decimal point? if ($v->fails()) { return $v->errors()->toArray(); } $amount = (int) round($request->amount * 100); // Validate the minimum value // It has to be at least minimum payment amount and must cover current debt, // and must be more than a yearly/monthly payment (according to the plan) $min = $wallet->getMinMandateAmount(); $label = 'minamount'; if ($wallet->balance < 0 && $wallet->balance < $min * -1) { $min = $wallet->balance * -1; $label = 'minamountdebt'; } if ($amount < $min) { return ['amount' => self::trans("validation.{$label}", ['amount' => $wallet->money($min)])]; } return null; } /** * Get status of the last payment. * * @return \Illuminate\Http\JsonResponse The response */ public function paymentStatus() { $user = $this->guard()->user(); $wallet = $user->wallets()->first(); $payment = $wallet->payments()->orderBy('created_at', 'desc')->first(); if (empty($payment)) { return $this->errorResponse(404); } $done = [Payment::STATUS_PAID, Payment::STATUS_CANCELED, Payment::STATUS_FAILED, Payment::STATUS_EXPIRED]; if (in_array($payment->status, $done)) { $label = "app.payment-status-{$payment->status}"; } else { $label = "app.payment-status-checking"; } return response()->json([ 'id' => $payment->id, 'status' => $payment->status, 'type' => $payment->type, 'statusMessage' => self::trans($label), 'description' => $payment->description, ]); } /** * Create a new payment. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $rules = [ 'amount' => 'required|numeric', ]; // Check required fields $v = Validator::make($request->all(), $rules); // TODO: allow comma as a decimal point? if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $amount = (int) round($request->amount * 100); // Validate the minimum value if ($amount < Payment::MIN_AMOUNT) { $min = $wallet->money(Payment::MIN_AMOUNT); $errors = ['amount' => self::trans('validation.minamount', ['amount' => $min])]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } $currency = $request->currency; $request = [ 'type' => Payment::TYPE_ONEOFF, 'currency' => $currency, 'amount' => $amount, 'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD, 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Payment', ]; self::addTax($wallet, $request); $provider = PaymentProvider::factory($wallet, $currency); $result = $provider->payment($wallet, $request); $result['status'] = 'success'; return response()->json($result); } /** * Delete a pending payment. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ // TODO currently unused // public function cancel(Request $request) // { // $user = $this->guard()->user(); // // TODO: Wallet selection // $wallet = $user->wallets()->first(); // $paymentId = $request->payment; // $user_owns_payment = Payment::where('id', $paymentId) // ->where('wallet_id', $wallet->id) // ->exists(); // if (!$user_owns_payment) { // return $this->errorResponse(404); // } // $provider = PaymentProvider::factory($wallet); // if ($provider->cancel($wallet, $paymentId)) { // $result = ['status' => 'success']; // return response()->json($result); // } // return $this->errorResponse(404); // } /** * Update payment status (and balance). * * @param string $provider Provider name * * @return \Illuminate\Http\Response The response */ public function webhook($provider) { $code = 200; if ($provider = PaymentProvider::factory($provider)) { $code = $provider->webhook(); } return response($code < 400 ? 'Success' : 'Server error', $code); } /** * Top up a wallet with a "recurring" payment. * * @param \App\Wallet $wallet The wallet to charge * * @return bool True if the payment has been initialized */ public static function topUpWallet(Wallet $wallet): bool { $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']); \Log::debug("Requested top-up for wallet {$wallet->id}"); if (!empty($settings['mandate_disabled'])) { \Log::debug("Top-up for wallet {$wallet->id}: mandate disabled"); return false; } $min_balance = (int) round(floatval($settings['mandate_balance']) * 100); $amount = (int) round(floatval($settings['mandate_amount']) * 100); // The wallet balance is greater than the auto-payment threshold if ($wallet->balance >= $min_balance) { // Do nothing return false; } $provider = PaymentProvider::factory($wallet); $mandate = (array) $provider->getMandate($wallet); if (empty($mandate['isValid'])) { - \Log::debug("Top-up for wallet {$wallet->id}: mandate invalid"); + \Log::warning("Top-up for wallet {$wallet->id}: mandate invalid"); 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 + \Log::warning("Top-up for wallet {$wallet->id}: mandate too little"); $wallet->setSetting('mandate_disabled', '1'); \App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet); return false; } $appName = Tenant::getConfig($wallet->owner->tenant_id, 'app.name'); $description = "{$appName} Recurring Payment"; if ($plan = $wallet->plan()) { if ($plan->months == 12) { $description = "{$appName} Annual Payment"; } elseif ($plan->months == 3) { $description = "{$appName} Quarterly Payment"; } elseif ($plan->months == 1) { $description = "{$appName} Monthly Payment"; } } $request = [ 'type' => Payment::TYPE_RECURRING, 'currency' => $wallet->currency, 'amount' => $amount, 'methodId' => PaymentProvider::METHOD_CREDITCARD, 'description' => $description, ]; self::addTax($wallet, $request); $result = $provider->payment($wallet, $request); return !empty($result); } /** * Returns auto-payment mandate info for the specified wallet * * @param \App\Wallet $wallet A wallet object * * @return array A mandate metadata */ public static function walletMandate(Wallet $wallet): array { $provider = PaymentProvider::factory($wallet); $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']); // Get the Mandate info $mandate = (array) $provider->getMandate($wallet); $mandate['amount'] = $mandate['minAmount'] = round($wallet->getMinMandateAmount() / 100, 2); $mandate['balance'] = 0; $mandate['isDisabled'] = !empty($mandate['id']) && $settings['mandate_disabled']; $mandate['isValid'] = !empty($mandate['isValid']); foreach (['amount', 'balance'] as $key) { if (($value = $settings["mandate_{$key}"]) !== null) { $mandate[$key] = $value; } } // Unrestrict the wallet owner if mandate is valid if (!empty($mandate['isValid']) && $wallet->owner->isRestricted()) { $wallet->owner->unrestrict(); } return $mandate; } /** * List supported payment methods. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function paymentMethods(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $methods = PaymentProvider::paymentMethods($wallet, $request->type); \Log::debug("Provider methods" . var_export(json_encode($methods), true)); return response()->json($methods); } /** * Check for pending payments. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function hasPayments(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $exists = Payment::where('wallet_id', $wallet->id) ->where('type', Payment::TYPE_ONEOFF) ->whereIn('status', [ Payment::STATUS_OPEN, Payment::STATUS_PENDING, Payment::STATUS_AUTHORIZED ]) ->exists(); return response()->json([ 'status' => 'success', 'hasPending' => $exists ]); } /** * List pending payments. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function payments(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $pageSize = 10; $page = intval(request()->input('page')) ?: 1; $hasMore = false; $result = Payment::where('wallet_id', $wallet->id) ->where('type', Payment::TYPE_ONEOFF) ->whereIn('status', [ Payment::STATUS_OPEN, Payment::STATUS_PENDING, Payment::STATUS_AUTHORIZED ]) ->orderBy('created_at', 'desc') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } $result = $result->map(function ($item) use ($wallet) { $provider = PaymentProvider::factory($item->provider); $payment = $provider->getPayment($item->id); $entry = [ 'id' => $item->id, 'createdAt' => $item->created_at->format('Y-m-d H:i'), 'type' => $item->type, 'description' => $item->description, 'amount' => $item->amount, 'currency' => $wallet->currency, // note: $item->currency/$item->currency_amount might be different 'status' => $item->status, 'isCancelable' => $payment['isCancelable'], 'checkoutUrl' => $payment['checkoutUrl'] ]; return $entry; }); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, 'page' => $page, ]); } /** * Calculates tax for the payment, fills the request with additional properties * * @param \App\Wallet $wallet The wallet * @param array $request The request data with the payment amount */ protected static function addTax(Wallet $wallet, array &$request): void { $request['vat_rate_id'] = null; $request['credit_amount'] = $request['amount']; if ($rate = $wallet->vatRate()) { $request['vat_rate_id'] = $rate->id; switch (\config('app.vat.mode')) { case 1: // In this mode tax is added on top of the payment. The amount // to pay grows, but we keep wallet balance without tax. $request['amount'] = $request['amount'] + round($request['amount'] * $rate->rate / 100); break; default: // In this mode tax is "swallowed" by the vendor. The payment // amount does not change break; } } } } diff --git a/src/app/Jobs/WalletCheck.php b/src/app/Jobs/WalletCheck.php index cf246138..8ed0f514 100644 --- a/src/app/Jobs/WalletCheck.php +++ b/src/app/Jobs/WalletCheck.php @@ -1,293 +1,296 @@ walletId = $walletId; } /** * Execute the job. * * @return ?string Executed action (THRESHOLD_*) */ public function handle() { $this->wallet = Wallet::find($this->walletId); // Sanity check (owner deleted in meantime) if (!$this->wallet || !$this->wallet->owner) { + \Log::warning( + "[WalletCheck] The wallet has been deleted in the meantime or doesn't have an owner {$this->walletId}." + ); return null; } if ($this->wallet->chargeEntitlements() > 0) { // We make a payment when there's a charge. If for some reason the // payment failed we can't just throw here, as another execution of this job // will not re-try the payment. So, we attempt a payment in a separate job. try { $this->topUpWallet(); } catch (\Exception $e) { \Log::error("Failed to top-up wallet {$this->walletId}: " . $e->getMessage()); WalletCharge::dispatch($this->wallet->id); } } if ($this->wallet->balance >= 0) { return null; } $now = Carbon::now(); $steps = [ // Send the initial reminder self::THRESHOLD_INITIAL => 'initialReminderForDegrade', // Try to top-up the wallet before the second reminder self::THRESHOLD_BEFORE_REMINDER => 'topUpWallet', // Send the second reminder self::THRESHOLD_REMINDER => 'secondReminderForDegrade', // Try to top-up the wallet before the account degradation self::THRESHOLD_BEFORE_DEGRADE => 'topUpWallet', // Degrade the account self::THRESHOLD_DEGRADE => 'degradeAccount', ]; if ($this->wallet->owner && $this->wallet->owner->isDegraded()) { $this->degradedReminder(); return self::THRESHOLD_DEGRADE_REMINDER; } foreach (array_reverse($steps, true) as $type => $method) { if (self::threshold($this->wallet, $type) < $now) { $this->{$method}(); return $type; } } return null; } /** * Send the initial reminder (for the process of degrading a account) */ protected function initialReminderForDegrade() { if ($this->wallet->getSetting('balance_warning_initial')) { return; } if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { return; } if (!$this->wallet->owner->isSuspended()) { $this->sendMail(\App\Mail\NegativeBalance::class, false); } $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_initial', $now); } /** * Send the second reminder (for the process of degrading a account) */ protected function secondReminderForDegrade() { if ($this->wallet->getSetting('balance_warning_reminder')) { return; } if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { return; } if (!$this->wallet->owner->isSuspended()) { $this->sendMail(\App\Mail\NegativeBalanceReminderDegrade::class, true); } $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_reminder', $now); } /** * Degrade the account */ protected function degradeAccount() { // The account may be already deleted, or degraded if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { return; } $email = $this->wallet->owner->email; // The dirty work will be done by UserObserver $this->wallet->owner->degrade(); \Log::info( sprintf( "[WalletCheck] Account degraded %s (%s)", $this->wallet->id, $email ) ); if (!$this->wallet->owner->isSuspended()) { $this->sendMail(\App\Mail\NegativeBalanceDegraded::class, true); } } /** * Send the periodic reminder to the degraded account owners */ protected function degradedReminder() { // Sanity check if (!$this->wallet->owner || !$this->wallet->owner->isDegraded()) { return; } if ($this->wallet->owner->isSuspended()) { return; } $now = \Carbon\Carbon::now(); $last = $this->wallet->getSetting('degraded_last_reminder'); if ($last) { $last = new Carbon($last); $period = 14; if ($last->addDays($period) > $now) { return; } $this->sendMail(\App\Mail\DegradedAccountReminder::class, false); } $this->wallet->setSetting('degraded_last_reminder', $now->toDateTimeString()); } /** * Send the email * * @param string $class Mailable class name * @param bool $with_external Use users's external email */ protected function sendMail($class, $with_external = false): void { // TODO: Send the email to all wallet controllers? $mail = new $class($this->wallet, $this->wallet->owner); list($to, $cc) = \App\Mail\Helper::userEmails($this->wallet->owner, $with_external); if (!empty($to) || !empty($cc)) { $params = [ 'to' => $to, 'cc' => $cc, 'add' => " for {$this->wallet->id}", ]; \App\Mail\Helper::sendMail($mail, $this->wallet->owner->tenant_id, $params); } } /** * Get the date-time for an action threshold. Calculated using * the date when a wallet balance turned negative. * * @param \App\Wallet $wallet A wallet * @param string $type Action type (one of self::THRESHOLD_*) * * @return \Carbon\Carbon The threshold date-time object */ public static function threshold(Wallet $wallet, string $type): ?Carbon { $negative_since = $wallet->getSetting('balance_negative_since'); // Migration scenario: balance<0, but no balance_negative_since set if (!$negative_since) { // 2h back from now, so first run can sent the initial notification $negative_since = Carbon::now()->subHours(2); $wallet->setSetting('balance_negative_since', $negative_since->toDateTimeString()); } else { $negative_since = new Carbon($negative_since); } // Initial notification // Give it an hour so the async recurring payment has a chance to be finished if ($type == self::THRESHOLD_INITIAL) { return $negative_since->addHours(1); } $thresholds = [ // A day before the second reminder self::THRESHOLD_BEFORE_REMINDER => 7 - 1, // Second notification self::THRESHOLD_REMINDER => 7, // Last chance to top-up the wallet self::THRESHOLD_BEFORE_DEGRADE => 13, // Account degradation self::THRESHOLD_DEGRADE => 14, ]; if (!empty($thresholds[$type])) { return $negative_since->addDays($thresholds[$type]); } return null; } /** * Try to automatically top-up the wallet */ protected function topUpWallet(): void { PaymentsController::topUpWallet($this->wallet); } }