diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php index 1c8462be..f03e0563 100644 --- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php @@ -1,148 +1,148 @@ errorResponse(404); } $result = $wallet->toArray(); $result['discount'] = 0; $result['discount_description'] = ''; if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } $result['mandate'] = PaymentsController::walletMandate($wallet); $provider = PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); $result['providerLink'] = $provider->customerLink($wallet); return response()->json($result); } /** * Award/penalize a wallet. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse The response */ public function oneOff(Request $request, $id) { $wallet = Wallet::find($id); if (empty($wallet)) { return $this->errorResponse(404); } // Check required fields $v = Validator::make( $request->all(), [ 'amount' => 'required|numeric', 'description' => 'required|string|max:1024', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $amount = (int) ($request->amount * 100); $type = $amount > 0 ? Transaction::WALLET_AWARD : Transaction::WALLET_PENALTY; DB::beginTransaction(); $wallet->balance += $amount; $wallet->save(); Transaction::create( [ 'user_email' => \App\Utils::userEmailOrNull(), 'object_id' => $wallet->id, 'object_type' => Wallet::class, 'type' => $type, - 'amount' => $amount < 0 ? $amount * -1 : $amount, + 'amount' => $amount, 'description' => $request->description ] ); DB::commit(); $response = [ 'status' => 'success', 'message' => \trans("app.wallet-{$type}-success"), 'balance' => $wallet->balance ]; return response()->json($response); } /** * Update wallet data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $wallet = Wallet::find($id); if (empty($wallet)) { return $this->errorResponse(404); } if (array_key_exists('discount', $request->input())) { if (empty($request->discount)) { $wallet->discount()->dissociate(); $wallet->save(); } elseif ($discount = Discount::find($request->discount)) { $wallet->discount()->associate($discount); $wallet->save(); } } $response = $wallet->toArray(); if ($wallet->discount) { $response['discount'] = $wallet->discount->discount; $response['discount_description'] = $wallet->discount->description; } $response['status'] = 'success'; $response['message'] = \trans('app.wallet-update-success'); return response()->json($response); } } diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php index ce1df494..481ebc1d 100644 --- a/src/app/Http/Controllers/API/V4/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/WalletsController.php @@ -1,334 +1,321 @@ errorResponse(404); } /** * Show the form for creating a new resource. * * @return \Illuminate\Http\JsonResponse */ public function create() { return $this->errorResponse(404); } /** * Store a newly created resource in storage. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { return $this->errorResponse(404); } /** * Return data of the specified wallet. * * @param string $id A wallet identifier * * @return \Illuminate\Http\JsonResponse The response */ public function show($id) { $wallet = Wallet::find($id); if (empty($wallet)) { return $this->errorResponse(404); } // Only owner (or admin) has access to the wallet if (!Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $result = $wallet->toArray(); $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); $result['notice'] = $this->getWalletNotice($wallet); return response()->json($result); } /** * Show the form for editing the specified resource. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { return $this->errorResponse(404); } /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request * @param string $id * * @return \Illuminate\Http\JsonResponse */ public function update(Request $request, $id) { return $this->errorResponse(404); } /** * Remove the specified resource from storage. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { return $this->errorResponse(404); } /** * Download a receipt in pdf format. * * @param string $id Wallet identifier * @param string $receipt Receipt identifier (YYYY-MM) * * @return \Illuminate\Http\Response */ public function receiptDownload($id, $receipt) { $wallet = Wallet::find($id); // Only owner (or admin) has access to the wallet if (!Auth::guard()->user()->canRead($wallet)) { abort(403); } list ($year, $month) = explode('-', $receipt); if (empty($year) || empty($month) || $year < 2000 || $month < 1 || $month > 12) { abort(404); } if ($receipt >= date('Y-m')) { abort(404); } $params = [ 'id' => sprintf('%04d-%02d', $year, $month), 'site' => \config('app.name') ]; $filename = \trans('documents.receipt-filename', $params); $receipt = new \App\Documents\Receipt($wallet, (int) $year, (int) $month); $content = $receipt->pdfOutput(); return response($content) ->withHeaders([ 'Content-Type' => 'application/pdf', 'Content-Disposition' => 'attachment; filename="' . $filename . '"', 'Content-Length' => strlen($content), ]); } /** * Fetch wallet receipts list. * * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse */ public function receipts($id) { $wallet = Wallet::find($id); // Only owner (or admin) has access to the wallet if (!Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $result = $wallet->payments() ->selectRaw('distinct date_format(updated_at, "%Y-%m") as ident') ->where('status', PaymentProvider::STATUS_PAID) ->where('amount', '<>', 0) ->orderBy('ident', 'desc') ->get() ->whereNotIn('ident', [date('Y-m')]) // exclude current month ->pluck('ident'); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => false, 'page' => 1, ]); } /** * Fetch wallet transactions. * * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse */ public function transactions($id) { $wallet = Wallet::find($id); // Only owner (or admin) has access to the wallet if (!Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $pageSize = 10; $page = intval(request()->input('page')) ?: 1; $hasMore = false; $isAdmin = $this instanceof Admin\WalletsController; if ($transaction = request()->input('transaction')) { // Get sub-transactions for the specified transaction ID, first // check access rights to the transaction's wallet $transaction = $wallet->transactions()->where('id', $transaction)->first(); if (!$transaction) { return $this->errorResponse(404); } $result = Transaction::where('transaction_id', $transaction->id)->get(); } else { // Get main transactions (paged) $result = $wallet->transactions() // FIXME: Do we know which (type of) transaction has sub-transactions // without the sub-query? ->selectRaw("*, (SELECT count(*) FROM transactions sub " . "WHERE sub.transaction_id = transactions.id) AS cnt") ->whereNull('transaction_id') ->latest() ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } } $result = $result->map(function ($item) use ($isAdmin) { - $amount = $item->amount; - - $negatives = [ - Transaction::WALLET_CHARGEBACK, - Transaction::WALLET_DEBIT, - Transaction::WALLET_PENALTY, - Transaction::WALLET_REFUND, - ]; - - if (in_array($item->type, $negatives)) { - $amount *= -1; - } - $entry = [ 'id' => $item->id, 'createdAt' => $item->created_at->format('Y-m-d H:i'), 'type' => $item->type, 'description' => $item->shortDescription(), - 'amount' => $amount, + 'amount' => $item->amount, 'hasDetails' => !empty($item->cnt), ]; if ($isAdmin && $item->user_email) { $entry['user'] = $item->user_email; } return $entry; }); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, 'page' => $page, ]); } /** * Returns human readable notice about the wallet state. * * @param \App\Wallet $wallet The wallet */ protected function getWalletNotice(Wallet $wallet): ?string { // there is no credit if ($wallet->balance < 0) { return \trans('app.wallet-notice-nocredit'); } // the discount is 100%, no credit is needed if ($wallet->discount && $wallet->discount->discount == 100) { return null; } // the owner was created less than a month ago if ($wallet->owner->created_at > Carbon::now()->subMonthsWithoutOverflow(1)) { // but more than two weeks ago, notice of trial ending if ($wallet->owner->created_at <= Carbon::now()->subWeeks(2)) { return \trans('app.wallet-notice-trial-end'); } return \trans('app.wallet-notice-trial'); } if ($until = $wallet->balanceLastsUntil()) { if ($until->isToday()) { return \trans('app.wallet-notice-today'); } // Once in a while we got e.g. "3 weeks" instead of expected "4 weeks". // It's because $until uses full seconds, but $now is more precise. // We make sure both have the same time set. $now = Carbon::now()->setTimeFrom($until); $params = [ 'date' => $until->toDateString(), 'days' => $now->diffForHumans($until, Carbon::DIFF_ABSOLUTE), ]; return \trans('app.wallet-notice-date', $params); } return null; } } diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php index 67dea4b7..aa27ceca 100644 --- a/src/app/Providers/PaymentProvider.php +++ b/src/app/Providers/PaymentProvider.php @@ -1,197 +1,197 @@ getSetting('stripe_id')) { $provider = 'stripe'; } elseif ($provider_or_wallet->getSetting('mollie_id')) { $provider = 'mollie'; } } else { $provider = $provider_or_wallet; } if (empty($provider)) { $provider = \config('services.payment_provider') ?: 'mollie'; } switch (\strtolower($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; /** * Get a link to the customer in the provider's control panel * * @param \App\Wallet $wallet The wallet * * @return string|null The string representing tag */ abstract public function customerLink(Wallet $wallet): ?string; /** * Get a provider name * * @return string Provider name */ abstract public function name(): string; /** * 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 * * @return \App\Payment Payment object */ 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'] ?? 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; } /** * Deduct an amount of pecunia from the wallet. * Creates a payment and transaction records for the refund/chargeback operation. * * @param \App\Wallet $wallet A wallet object * @param array $refund A refund or chargeback data (id, type, amount, description) * * @return void */ protected function storeRefund(Wallet $wallet, array $refund): void { if (empty($refund) || empty($refund['amount'])) { return; } $wallet->balance -= $refund['amount']; $wallet->save(); if ($refund['type'] == self::TYPE_CHARGEBACK) { $transaction_type = Transaction::WALLET_CHARGEBACK; } else { $transaction_type = Transaction::WALLET_REFUND; } Transaction::create([ 'object_id' => $wallet->id, 'object_type' => Wallet::class, 'type' => $transaction_type, - 'amount' => $refund['amount'], + 'amount' => $refund['amount'] * -1, 'description' => $refund['description'] ?? '', ]); $refund['status'] = self::STATUS_PAID; $refund['amount'] *= -1; $this->storePayment($refund, $wallet->id); } } diff --git a/src/app/Transaction.php b/src/app/Transaction.php index 08138ff4..684d8f3a 100644 --- a/src/app/Transaction.php +++ b/src/app/Transaction.php @@ -1,197 +1,199 @@ 'integer', ]; /** @var boolean This model uses an automatically incrementing integer primary key? */ public $incrementing = false; /** @var string The type of the primary key */ protected $keyType = 'string'; /** * Returns the entitlement to which the transaction is assigned (if any) * * @return \App\Entitlement|null The entitlement */ public function entitlement(): ?Entitlement { if ($this->object_type !== Entitlement::class) { return null; } return Entitlement::withTrashed()->find($this->object_id); } /** * Transaction type mutator * * @throws \Exception */ public function setTypeAttribute($value): void { switch ($value) { case self::ENTITLEMENT_BILLED: case self::ENTITLEMENT_CREATED: case self::ENTITLEMENT_DELETED: // TODO: Must be an entitlement. $this->attributes['type'] = $value; break; case self::WALLET_AWARD: case self::WALLET_CREDIT: case self::WALLET_DEBIT: case self::WALLET_PENALTY: case self::WALLET_REFUND: case self::WALLET_CHARGEBACK: // TODO: This must be a wallet. $this->attributes['type'] = $value; break; default: throw new \Exception("Invalid type value"); } } /** * Returns a short text describing the transaction. * * @return string The description */ public function shortDescription(): string { $label = $this->objectTypeToLabelString() . '-' . $this->{'type'} . '-short'; $result = \trans("transactions.{$label}", $this->descriptionParams()); return trim($result, ': '); } /** * Returns a text describing the transaction. * * @return string The description */ public function toString(): string { $label = $this->objectTypeToLabelString() . '-' . $this->{'type'}; return \trans("transactions.{$label}", $this->descriptionParams()); } /** * Returns a wallet to which the transaction is assigned (if any) * * @return \App\Wallet|null The wallet */ public function wallet(): ?Wallet { if ($this->object_type !== Wallet::class) { return null; } return Wallet::find($this->object_id); } /** * Collect transaction parameters used in (localized) descriptions * * @return array Parameters */ private function descriptionParams(): array { $result = [ 'user_email' => $this->user_email, 'description' => $this->{'description'}, ]; + $amount = $this->amount * ($this->amount < 0 ? -1 : 1); + if ($entitlement = $this->entitlement()) { $wallet = $entitlement->wallet; $cost = $entitlement->cost; $discount = $entitlement->wallet->getDiscountRate(); $result['entitlement_cost'] = $cost * $discount; $result['object'] = $entitlement->entitleableTitle(); $result['sku_title'] = $entitlement->sku->{'title'}; } else { $wallet = $this->wallet(); } $result['wallet'] = $wallet->{'description'} ?: 'Default wallet'; - $result['amount'] = $wallet->money($this->amount); + $result['amount'] = $wallet->money($amount); return $result; } /** * Get a string for use in translation tables derived from the object type. * * @return string|null */ private function objectTypeToLabelString(): ?string { if ($this->object_type == Entitlement::class) { return 'entitlement'; } if ($this->object_type == Wallet::class) { return 'wallet'; } return null; } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index e5ee16de..8c0a77ec 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,400 +1,400 @@ 0, 'currency' => 'CHF' ]; protected $fillable = [ 'currency' ]; protected $nullable = [ 'description', ]; protected $casts = [ 'balance' => 'integer', ]; /** * Add a controller to this wallet. * * @param \App\User $user The user to add as a controller to this wallet. * * @return void */ public function addController(User $user) { if (!$this->controllers->contains($user)) { $this->controllers()->save($user); } } public function chargeEntitlements($apply = true) { // This wallet has been created less than a month ago, this is the trial period if ($this->owner->created_at >= Carbon::now()->subMonthsWithoutOverflow(1)) { // Move all the current entitlement's updated_at timestamps forward to one month after // this wallet was created. $freeMonthEnds = $this->owner->created_at->copy()->addMonthsWithoutOverflow(1); foreach ($this->entitlements()->get()->fresh() as $entitlement) { if ($entitlement->updated_at < $freeMonthEnds) { $entitlement->updated_at = $freeMonthEnds; $entitlement->save(); } } return 0; } $charges = 0; $discount = $this->getDiscountRate(); DB::beginTransaction(); // used to parent individual entitlement billings to the wallet debit. $entitlementTransactions = []; foreach ($this->entitlements()->get()->fresh() as $entitlement) { // This entitlement has been created less than or equal to 14 days ago (this is at // maximum the fourteenth 24-hour period). if ($entitlement->created_at > Carbon::now()->subDays(14)) { continue; } // This entitlement was created, or billed last, less than a month ago. if ($entitlement->updated_at > Carbon::now()->subMonthsWithoutOverflow(1)) { continue; } // updated last more than a month ago -- was it billed? if ($entitlement->updated_at <= Carbon::now()->subMonthsWithoutOverflow(1)) { $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); $cost = (int) ($entitlement->cost * $discount * $diff); $charges += $cost; // if we're in dry-run, you know... if (!$apply) { continue; } $entitlement->updated_at = $entitlement->updated_at->copy() ->addMonthsWithoutOverflow($diff); $entitlement->save(); if ($cost == 0) { continue; } $entitlementTransactions[] = $entitlement->createTransaction( \App\Transaction::ENTITLEMENT_BILLED, $cost ); } } if ($apply) { $this->debit($charges, $entitlementTransactions); } DB::commit(); return $charges; } /** * Calculate for how long the current balance will last. * * Returns NULL for balance < 0 or discount = 100% or on a fresh account * * @return \Carbon\Carbon|null Date */ public function balanceLastsUntil() { if ($this->balance < 0 || $this->getDiscount() == 100) { return null; } // retrieve any expected charges $expectedCharge = $this->expectedCharges(); // get the costs per day for all entitlements billed against this wallet $costsPerDay = $this->costsPerDay(); if (!$costsPerDay) { return null; } // the number of days this balance, minus the expected charges, would last $daysDelta = ($this->balance - $expectedCharge) / $costsPerDay; // calculate from the last entitlement billed $entitlement = $this->entitlements()->orderBy('updated_at', 'desc')->first(); $until = $entitlement->updated_at->copy()->addDays($daysDelta); // Don't return dates from the past if ($until < Carbon::now() && !$until->isToday()) { return null; } return $until; } /** * Controllers of this wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function controllers() { return $this->belongsToMany( 'App\User', // The foreign object definition 'user_accounts', // The table name 'wallet_id', // The local foreign key 'user_id' // The remote foreign key ); } /** * Retrieve the costs per day of everything charged to this wallet. * * @return float */ public function costsPerDay() { $costs = (float) 0; foreach ($this->entitlements as $entitlement) { $costs += $entitlement->costsPerDay(); } return $costs; } /** * Add an amount of pecunia to this wallet's balance. * * @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, string $description = ''): Wallet { $this->balance += $amount; $this->save(); \App\Transaction::create( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class, 'type' => \App\Transaction::WALLET_CREDIT, 'amount' => $amount, 'description' => $description ] ); return $this; } /** * Deduct an amount of pecunia from this wallet's balance. * * @param int $amount The amount of pecunia to deduct (in cents). * @param array $eTIDs List of transaction IDs for the individual entitlements that make up * this debit record, if any. * @return Wallet Self */ public function debit(int $amount, array $eTIDs = []): Wallet { if ($amount == 0) { return $this; } $this->balance -= $amount; $this->save(); $transaction = \App\Transaction::create( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class, 'type' => \App\Transaction::WALLET_DEBIT, - 'amount' => $amount + 'amount' => $amount * -1 ] ); \App\Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); return $this; } /** * The discount assigned to the wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function discount() { return $this->belongsTo('App\Discount', 'discount_id', 'id'); } /** * Entitlements billed to this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement'); } /** * Calculate the expected charges to this wallet. * * @return int */ public function expectedCharges() { return $this->chargeEntitlements(false); } /** * Return the exact, numeric version of the discount to be applied. * * Ranges from 0 - 100. * * @return int */ public function getDiscount() { return $this->discount ? $this->discount->discount : 0; } /** * The actual discount rate for use in multiplication * * Ranges from 0.00 to 1.00. */ public function getDiscountRate() { return (100 - $this->getDiscount()) / 100; } /** * 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::CURRENCY); $result = $nf->formatCurrency($amount, $this->currency); // Replace non-breaking space return str_replace("\xC2\xA0", " ", $result); } return sprintf('%.2f %s', $amount, $this->currency); } /** * The owner of the wallet -- the wallet is in his/her back pocket. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { 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'); } /** * Remove a controller from this wallet. * * @param \App\User $user The user to remove as a controller from this wallet. * * @return void */ public function removeController(User $user) { if ($this->controllers->contains($user)) { $this->controllers()->detach($user); } } /** * Any (additional) properties of this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\WalletSetting'); } /** * Retrieve the transactions against this wallet. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function transactions() { return \App\Transaction::where( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class ] ); } } diff --git a/src/database/migrations/2021_02_19_100000_transaction_amount_fix.php b/src/database/migrations/2021_02_19_100000_transaction_amount_fix.php new file mode 100644 index 00000000..19277aa2 --- /dev/null +++ b/src/database/migrations/2021_02_19_100000_transaction_amount_fix.php @@ -0,0 +1,38 @@ + 'stripe']); $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $wallet = $user->wallets()->first(); $wallet->discount_id = null; $wallet->save(); // Make sure there's no stripe/mollie identifiers $wallet->setSetting('stripe_id', null); $wallet->setSetting('stripe_mandate_id', null); $wallet->setSetting('mollie_id', null); $wallet->setSetting('mollie_mandate_id', null); // Non-admin user $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame($wallet->id, $json['id']); $this->assertSame('CHF', $json['currency']); $this->assertSame($wallet->balance, $json['balance']); $this->assertSame(0, $json['discount']); $this->assertTrue(empty($json['description'])); $this->assertTrue(empty($json['discount_description'])); $this->assertTrue(!empty($json['provider'])); $this->assertTrue(empty($json['providerLink'])); $this->assertTrue(!empty($json['mandate'])); } /** * Test awarding/penalizing a wallet (POST /api/v4/wallets/:id/one-off) */ public function testOneOff(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $wallet = $user->wallets()->first(); $balance = $wallet->balance; Transaction::where('object_id', $wallet->id) ->whereIn('type', [Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY]) ->delete(); // Non-admin user $response = $this->actingAs($user)->post("api/v4/wallets/{$wallet->id}/one-off", []); $response->assertStatus(403); // Admin user - invalid input $post = ['amount' => 'aaaa']; $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('The amount must be a number.', $json['errors']['amount'][0]); $this->assertSame('The description field is required.', $json['errors']['description'][0]); $this->assertCount(2, $json); $this->assertCount(2, $json['errors']); // Admin user - a valid bonus $post = ['amount' => '50', 'description' => 'A bonus']; $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The bonus has been added to the wallet successfully.', $json['message']); $this->assertSame($balance += 5000, $json['balance']); $this->assertSame($balance, $wallet->fresh()->balance); $transaction = Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_AWARD)->first(); $this->assertSame($post['description'], $transaction->description); $this->assertSame(5000, $transaction->amount); $this->assertSame($admin->email, $transaction->user_email); // Admin user - a valid penalty $post = ['amount' => '-40', 'description' => 'A penalty']; $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The penalty has been added to the wallet successfully.', $json['message']); $this->assertSame($balance -= 4000, $json['balance']); $this->assertSame($balance, $wallet->fresh()->balance); $transaction = Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_PENALTY)->first(); $this->assertSame($post['description'], $transaction->description); - $this->assertSame(4000, $transaction->amount); + $this->assertSame(-4000, $transaction->amount); $this->assertSame($admin->email, $transaction->user_email); } /** * Test fetching wallet transactions (GET /api/v4/wallets/:id/transactions) */ public function testTransactions(): void { // Note: Here we're testing only that the end-point works, // and admin can get the transaction log, response details // are tested in Feature/Controller/WalletsTest.php $this->deleteTestUser('wallets-controller@kolabnow.com'); $user = $this->getTestUser('wallets-controller@kolabnow.com'); $wallet = $user->wallets()->first(); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Non-admin $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); // Create some sample transactions $transactions = $this->createTestTransactions($wallet); $transactions = array_reverse($transactions); $pages = array_chunk($transactions, 10 /* page size*/); // Get the 2nd page $response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}/transactions?page=2"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(2, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); foreach ($pages[1] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertFalse($json['list'][$idx]['hasDetails']); } // The 'user' key is set only on the admin end-point $this->assertSame('jeroen@jeroen.jeroen', $json['list'][1]['user']); } /** * Test updating a wallet (PUT /api/v4/wallets/:id) */ public function testUpdate(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $wallet = $user->wallets()->first(); $discount = Discount::where('code', 'TEST')->first(); // Non-admin user $response = $this->actingAs($user)->put("api/v4/wallets/{$wallet->id}", []); $response->assertStatus(403); // Admin user - setting a discount $post = ['discount' => $discount->id]; $response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('User wallet updated successfully.', $json['message']); $this->assertSame($wallet->id, $json['id']); $this->assertSame($discount->discount, $json['discount']); $this->assertSame($discount->id, $json['discount_id']); $this->assertSame($discount->description, $json['discount_description']); $this->assertSame($discount->id, $wallet->fresh()->discount->id); // Admin user - removing a discount $post = ['discount' => null]; $response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('User wallet updated successfully.', $json['message']); $this->assertSame($wallet->id, $json['id']); $this->assertSame(null, $json['discount_id']); $this->assertTrue(empty($json['discount_description'])); $this->assertSame(null, $wallet->fresh()->discount); } } diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php index 9aa36824..ef77477a 100644 --- a/src/tests/Feature/Controller/PaymentsMollieTest.php +++ b/src/tests/Feature/Controller/PaymentsMollieTest.php @@ -1,815 +1,815 @@ 'mollie']); $john = $this->getTestUser('john@kolab.org'); $wallet = $john->wallets()->first(); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); $types = [ Transaction::WALLET_CREDIT, Transaction::WALLET_REFUND, Transaction::WALLET_CHARGEBACK, ]; Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); $wallet = $john->wallets()->first(); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); $types = [ Transaction::WALLET_CREDIT, Transaction::WALLET_REFUND, Transaction::WALLET_CHARGEBACK, ]; Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete(); parent::tearDown(); } /** * Test creating/updating/deleting an outo-payment mandate * * @group mollie */ public function testMandates(): void { // Unauth access not allowed $response = $this->get("api/v4/payments/mandate"); $response->assertStatus(401); $response = $this->post("api/v4/payments/mandate", []); $response->assertStatus(401); $response = $this->put("api/v4/payments/mandate", []); $response->assertStatus(401); $response = $this->delete("api/v4/payments/mandate"); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Test creating a mandate (invalid input) $post = []; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); // Test creating a mandate (invalid input) $post = ['amount' => 100, 'balance' => 'a']; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame('The balance must be a number.', $json['errors']['balance'][0]); // Test creating a mandate (amount smaller than the minimum value) $post = ['amount' => -100, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); // Test creating a mandate (negative balance, amount too small) Wallet::where('id', $wallet->id)->update(['balance' => -2000]); $post = ['amount' => PaymentProvider::MIN_AMOUNT / 100, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("The specified amount does not cover the balance on the account.", $json['errors']['amount']); // Test creating a mandate (valid input) $post = ['amount' => 20.10, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']); // Assert the proper payment amount has been used $payment = Payment::where('id', $json['id'])->first(); $this->assertSame(2010, $payment->amount); $this->assertSame($wallet->id, $payment->wallet_id); $this->assertSame("Kolab Now Auto-Payment Setup", $payment->description); $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type); // Test fetching the mandate information $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertEquals('Credit Card', $json['method']); $this->assertSame(true, $json['isPending']); $this->assertSame(false, $json['isValid']); $this->assertSame(false, $json['isDisabled']); $mandate_id = $json['id']; // We would have to invoke a browser to accept the "first payment" to make // the mandate validated/completed. Instead, we'll mock the mandate object. $mollie_response = [ 'resource' => 'mandate', 'id' => $mandate_id, 'status' => 'valid', 'method' => 'creditcard', 'details' => [ 'cardNumber' => '4242', 'cardLabel' => 'Visa', ], 'customerId' => 'cst_GMfxGPt7Gj', 'createdAt' => '2020-04-28T11:09:47+00:00', ]; $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $wallet = $user->wallets()->first(); $wallet->setSetting('mandate_disabled', 1); $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertEquals('Visa (**** **** **** 4242)', $json['method']); $this->assertSame(false, $json['isPending']); $this->assertSame(true, $json['isValid']); $this->assertSame(true, $json['isDisabled']); Bus::fake(); $wallet->setSetting('mandate_disabled', null); $wallet->balance = 1000; $wallet->save(); // Test updating mandate details (invalid input) $post = []; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); $post = ['amount' => -100, 'balance' => 0]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); // Test updating a mandate (valid input) $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['amount' => 30.10, 'balance' => 10]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); $this->assertSame($mandate_id, $json['id']); $this->assertFalse($json['isDisabled']); $wallet->refresh(); $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); $this->assertEquals(10, $wallet->getSetting('mandate_balance')); Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 0); // Test updating a disabled mandate (invalid input) $wallet->setSetting('mandate_disabled', 1); $wallet->balance = -2000; $wallet->save(); $user->refresh(); // required so the controller sees the wallet update from above $post = ['amount' => 15.10, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']); // Test updating a disabled mandate (valid input) $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['amount' => 30, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); $this->assertSame($mandate_id, $json['id']); $this->assertFalse($json['isDisabled']); Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1); Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { $job_wallet = $this->getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); $this->unmockMollie(); // Delete mandate $response = $this->actingAs($user)->delete("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been removed.', $json['message']); // Confirm with Mollie the mandate does not exist $customer_id = $wallet->getSetting('mollie_id'); $this->expectException(\Mollie\Api\Exceptions\ApiException::class); $this->expectExceptionMessageMatches('/410: Gone/'); $mandate = mollie()->mandates()->getForId($customer_id, $mandate_id); $this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id')); // Test Mollie's "410 Gone" response handling when fetching the mandate info // It is expected to remove the mandate reference $mollie_response = [ 'status' => 410, 'title' => "Gone", 'detail' => "You are trying to access an object, which has previously been deleted", '_links' => [ 'documentation' => [ 'href' => "https://docs.mollie.com/errors", 'type' => "text/html" ] ] ]; $responseStack = $this->mockMollie(); $responseStack->append(new Response(410, [], json_encode($mollie_response))); $wallet->fresh()->setSetting('mollie_mandate_id', '123'); $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse(array_key_exists('id', $json)); $this->assertFalse(array_key_exists('method', $json)); $this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id')); } /** * Test creating a payment and receiving a status via webhook * * @group mollie */ public function testStoreAndWebhook(): void { Bus::fake(); // Unauth access not allowed $response = $this->post("api/v4/payments", []); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); $post = ['amount' => -1]; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); $post = ['amount' => '12.34']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']); $wallet = $user->wallets()->first(); $payments = Payment::where('wallet_id', $wallet->id)->get(); $this->assertCount(1, $payments); $payment = $payments[0]; $this->assertSame(1234, $payment->amount); $this->assertSame(\config('app.name') . ' Payment', $payment->description); $this->assertSame('open', $payment->status); $this->assertEquals(0, $wallet->balance); // Test the webhook // Note: Webhook end-point does not require authentication $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(1234, $transaction->amount); $this->assertSame( "Payment transaction {$payment->id} using Mollie", $transaction->description ); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); // Verify "paid -> open -> paid" scenario, assert that balance didn't change $mollie_response['status'] = 'open'; unset($mollie_response['paidAt']); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); $mollie_response['status'] = 'paid'; $mollie_response['paidAt'] = date('c'); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Test for payment failure Bus::fake(); $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "failed", "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame('failed', $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); } /** * Test automatic payment charges * * @group mollie */ public function testTopUp(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Create a valid mandate first (balance=0, so there's no extra payment yet) $this->createMandate($wallet, ['amount' => 20.10, 'balance' => 0]); $wallet->setSetting('mandate_balance', 10); // Expect a recurring payment as we have a valid mandate at this point // and the balance is below the threshold $result = PaymentsController::topUpWallet($wallet); $this->assertTrue($result); // Check that the payments table contains a new record with proper amount. // There should be two records, one for the mandate payment and another for // the top-up payment $payments = $wallet->payments()->orderBy('amount')->get(); $this->assertCount(2, $payments); $this->assertSame(0, $payments[0]->amount); $this->assertSame(2010, $payments[1]->amount); $payment = $payments[1]; // In mollie we don't have to wait for a webhook, the response to // PaymentIntent already sets the status to 'paid', so we can test // immediately the balance update // Assert that email notification job has been dispatched $this->assertSame(PaymentProvider::STATUS_PAID, $payment->status); $this->assertEquals(2010, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(2010, $transaction->amount); $this->assertSame( "Auto-payment transaction {$payment->id} using Mastercard (**** **** **** 6787)", $transaction->description ); Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); // Expect no payment if the mandate is disabled $wallet->setSetting('mandate_disabled', 1); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); // Expect no payment if balance is ok $wallet->setSetting('mandate_disabled', null); $wallet->balance = 1000; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); // Expect no payment if the top-up amount is not enough $wallet->setSetting('mandate_disabled', null); $wallet->balance = -2050; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) { $job_wallet = $this->getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); // Expect no payment if there's no mandate $wallet->setSetting('mollie_mandate_id', null); $wallet->balance = 0; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); // Test webhook for recurring payments $wallet->transactions()->delete(); $responseStack = $this->mockMollie(); Bus::fake(); $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(2010, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(2010, $transaction->amount); $this->assertSame( "Auto-payment transaction {$payment->id} using Mollie", $transaction->description ); // Assert that email notification job has been dispatched Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); Bus::fake(); // Test for payment failure $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $wallet->setSetting('mollie_mandate_id', 'xxx'); $wallet->setSetting('mandate_disabled', null); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "failed", "mode" => "test", ]; $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status); $this->assertEquals(2010, $wallet->balance); $this->assertTrue(!empty($wallet->getSetting('mandate_disabled'))); // Assert that email notification job has been dispatched Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); $this->unmockMollie(); } /** * Test refund/chargeback handling by the webhook * * @group mollie */ public function testRefundAndChargeback(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->transactions()->delete(); $mollie = PaymentProvider::factory('mollie'); // Create a paid payment $payment = Payment::create([ 'id' => 'tr_123456', 'status' => PaymentProvider::STATUS_PAID, 'amount' => 123, 'type' => PaymentProvider::TYPE_ONEOFF, 'wallet_id' => $wallet->id, 'provider' => 'mollie', 'description' => 'test', ]); // Test handling a refund by the webhook $mollie_response1 = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", "_links" => [ "refunds" => [ "href" => "https://api.mollie.com/v2/payments/{$payment->id}/refunds", "type" => "application/hal+json" ] ] ]; $mollie_response2 = [ "count" => 1, "_links" => [], "_embedded" => [ "refunds" => [ [ "resource" => "refund", "id" => "re_123456", "status" => \Mollie\Api\Types\RefundStatus::STATUS_REFUNDED, "paymentId" => $payment->id, "description" => "refund desc", "amount" => [ "currency" => "CHF", "value" => "1.01", ], ] ] ] ]; // We'll trigger the webhook with payment id and use mocking for // requests to the Mollie payments API. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response1))); $responseStack->append(new Response(200, [], json_encode($mollie_response2))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertEquals(-101, $wallet->balance); $transactions = $wallet->transactions()->where('type', Transaction::WALLET_REFUND)->get(); $this->assertCount(1, $transactions); - $this->assertSame(101, $transactions[0]->amount); + $this->assertSame(-101, $transactions[0]->amount); $this->assertSame(Transaction::WALLET_REFUND, $transactions[0]->type); $this->assertSame("refund desc", $transactions[0]->description); $payments = $wallet->payments()->where('id', 're_123456')->get(); $this->assertCount(1, $payments); $this->assertSame(-101, $payments[0]->amount); $this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status); $this->assertSame(PaymentProvider::TYPE_REFUND, $payments[0]->type); $this->assertSame("mollie", $payments[0]->provider); $this->assertSame("refund desc", $payments[0]->description); // Test handling a chargeback by the webhook $mollie_response1["_links"] = [ "chargebacks" => [ "href" => "https://api.mollie.com/v2/payments/{$payment->id}/chargebacks", "type" => "application/hal+json" ] ]; $mollie_response2 = [ "count" => 1, "_links" => [], "_embedded" => [ "chargebacks" => [ [ "resource" => "chargeback", "id" => "chb_123456", "paymentId" => $payment->id, "amount" => [ "currency" => "CHF", "value" => "0.15", ], ] ] ] ]; // We'll trigger the webhook with payment id and use mocking for // requests to the Mollie payments API. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response1))); $responseStack->append(new Response(200, [], json_encode($mollie_response2))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertEquals(-116, $wallet->balance); $transactions = $wallet->transactions()->where('type', Transaction::WALLET_CHARGEBACK)->get(); $this->assertCount(1, $transactions); - $this->assertSame(15, $transactions[0]->amount); + $this->assertSame(-15, $transactions[0]->amount); $this->assertSame(Transaction::WALLET_CHARGEBACK, $transactions[0]->type); $this->assertSame('', $transactions[0]->description); $payments = $wallet->payments()->where('id', 'chb_123456')->get(); $this->assertCount(1, $payments); $this->assertSame(-15, $payments[0]->amount); $this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status); $this->assertSame(PaymentProvider::TYPE_CHARGEBACK, $payments[0]->type); $this->assertSame("mollie", $payments[0]->provider); $this->assertSame('', $payments[0]->description); Bus::assertNotDispatched(\App\Jobs\PaymentEmail::class); $this->unmockMollie(); } /** * Create Mollie's auto-payment mandate using our API and Chrome browser */ protected function createMandate(Wallet $wallet, array $params) { // Use the API to create a first payment with a mandate $response = $this->actingAs($wallet->owner)->post("api/v4/payments/mandate", $params); $response->assertStatus(200); $json = $response->json(); // There's no easy way to confirm a created mandate. // The only way seems to be to fire up Chrome on checkout page // and do actions with use of Dusk browser. $this->startBrowser() ->visit($json['redirectUrl']) ->click('input[value="paid"]') ->click('button.form__button'); $this->stopBrowser(); } } diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php index c8bf36bf..e27166f4 100644 --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -1,244 +1,245 @@ entitlements()->get() ->map(function ($ent) { return $ent->sku->title; }) ->toArray(); sort($skus); Assert::assertSame($expected, $skus); } /** * Removes all beta entitlements from the database */ protected function clearBetaEntitlements(): void { $betas = \App\Sku::where('handler_class', 'like', 'App\\Handlers\\Beta\\%') ->orWhere('handler_class', 'App\Handlers\Beta') ->pluck('id')->all(); \App\Entitlement::whereIn('sku_id', $betas)->delete(); } /** * Creates the application. * * @return \Illuminate\Foundation\Application */ public function createApplication() { $app = require __DIR__ . '/../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); return $app; } /** * Create a set of transaction log entries for a wallet */ protected function createTestTransactions($wallet) { $result = []; $date = Carbon::now(); $debit = 0; $entitlementTransactions = []; foreach ($wallet->entitlements as $entitlement) { if ($entitlement->cost) { $debit += $entitlement->cost; $entitlementTransactions[] = $entitlement->createTransaction( Transaction::ENTITLEMENT_BILLED, $entitlement->cost ); } } $transaction = Transaction::create([ 'user_email' => 'jeroen@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_DEBIT, - 'amount' => $debit, + 'amount' => $debit * -1, 'description' => 'Payment', ]); $result[] = $transaction; Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]); $transaction = Transaction::create([ 'user_email' => null, 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_CREDIT, 'amount' => 2000, 'description' => 'Payment', ]); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; $types = [ Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY, ]; // The page size is 10, so we generate so many to have at least two pages $loops = 10; while ($loops-- > 0) { + $type = $types[count($result) % count($types)]; $transaction = Transaction::create([ 'user_email' => 'jeroen.@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, - 'type' => $types[count($result) % count($types)], - 'amount' => 11 * (count($result) + 1), + 'type' => $type, + 'amount' => 11 * (count($result) + 1) * ($type == Transaction::WALLET_PENALTY ? -1 : 1), 'description' => 'TRANS' . $loops, ]); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; } return $result; } protected function deleteTestDomain($name) { Queue::fake(); $domain = Domain::withTrashed()->where('namespace', $name)->first(); if (!$domain) { return; } $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $domain->forceDelete(); } protected function deleteTestGroup($email) { Queue::fake(); $group = Group::withTrashed()->where('email', $email)->first(); if (!$group) { return; } $job = new \App\Jobs\Group\DeleteJob($group->id); $job->handle(); $group->forceDelete(); } protected function deleteTestUser($email) { Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } $job = new \App\Jobs\User\DeleteJob($user->id); $job->handle(); $user->forceDelete(); } /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get Group object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestGroup($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Group::firstOrCreate(['email' => $email], $attrib); } /** * Get User object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestUser($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $user = User::firstOrCreate(['email' => $email], $attrib); if ($user->trashed()) { // Note: we do not want to use user restore here User::where('id', $user->id)->forceDelete(); $user = User::create(['email' => $email] + $attrib); } return $user; } /** * Helper to access protected property of an object */ protected static function getObjectProperty($object, $property_name) { $reflection = new \ReflectionClass($object); $property = $reflection->getProperty($property_name); $property->setAccessible(true); return $property->getValue($object); } /** * Call protected/private method of a class. * * @param object $object Instantiated object that we will run method on. * @param string $methodName Method name to call * @param array $parameters Array of parameters to pass into method. * * @return mixed Method return. */ protected function invokeMethod($object, $methodName, array $parameters = array()) { $reflection = new \ReflectionClass(get_class($object)); $method = $reflection->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $parameters); } } diff --git a/src/tests/Unit/TransactionTest.php b/src/tests/Unit/TransactionTest.php index 15c9976b..89898ba2 100644 --- a/src/tests/Unit/TransactionTest.php +++ b/src/tests/Unit/TransactionTest.php @@ -1,205 +1,205 @@ delete(); $user = $this->getTestUser('jane@kolabnow.com'); $wallet = $user->wallets()->first(); // Create transactions $transaction = Transaction::create([ 'object_id' => $wallet->id, 'object_type' => Wallet::class, 'type' => Transaction::WALLET_PENALTY, - 'amount' => 9, + 'amount' => -10, 'description' => "A test penalty" ]); $transaction = Transaction::create([ 'object_id' => $wallet->id, 'object_type' => Wallet::class, 'type' => Transaction::WALLET_DEBIT, - 'amount' => 10 + 'amount' => -9 ]); $transaction = Transaction::create([ 'object_id' => $wallet->id, 'object_type' => Wallet::class, 'type' => Transaction::WALLET_CREDIT, 'amount' => 11 ]); $transaction = Transaction::create([ 'object_id' => $wallet->id, 'object_type' => Wallet::class, 'type' => Transaction::WALLET_AWARD, 'amount' => 12, 'description' => "A test award" ]); $sku = Sku::where('title', 'mailbox')->first(); $entitlement = Entitlement::where('sku_id', $sku->id)->first(); $transaction = Transaction::create([ 'user_email' => 'test@test.com', 'object_id' => $entitlement->id, 'object_type' => Entitlement::class, 'type' => Transaction::ENTITLEMENT_CREATED, 'amount' => 13 ]); $sku = Sku::where('title', 'domain-hosting')->first(); $entitlement = Entitlement::where('sku_id', $sku->id)->first(); $transaction = Transaction::create([ 'user_email' => 'test@test.com', 'object_id' => $entitlement->id, 'object_type' => Entitlement::class, 'type' => Transaction::ENTITLEMENT_BILLED, 'amount' => 14 ]); $sku = Sku::where('title', 'storage')->first(); $entitlement = Entitlement::where('sku_id', $sku->id)->first(); $transaction = Transaction::create([ 'user_email' => 'test@test.com', 'object_id' => $entitlement->id, 'object_type' => Entitlement::class, 'type' => Transaction::ENTITLEMENT_DELETED, 'amount' => 15 ]); $transactions = Transaction::where('amount', '<', 20)->orderBy('amount')->get(); - $this->assertSame(9, $transactions[0]->amount); + $this->assertSame(-10, $transactions[0]->amount); $this->assertSame(Transaction::WALLET_PENALTY, $transactions[0]->type); $this->assertSame( - "The balance of Default wallet was reduced by 0,09 CHF; A test penalty", + "The balance of Default wallet was reduced by 0,10 CHF; A test penalty", $transactions[0]->toString() ); $this->assertSame( "Charge: A test penalty", $transactions[0]->shortDescription() ); - $this->assertSame(10, $transactions[1]->amount); + $this->assertSame(-9, $transactions[1]->amount); $this->assertSame(Transaction::WALLET_DEBIT, $transactions[1]->type); $this->assertSame( - "0,10 CHF was deducted from the balance of Default wallet", + "0,09 CHF was deducted from the balance of Default wallet", $transactions[1]->toString() ); $this->assertSame( "Deduction", $transactions[1]->shortDescription() ); $this->assertSame(11, $transactions[2]->amount); $this->assertSame(Transaction::WALLET_CREDIT, $transactions[2]->type); $this->assertSame( "0,11 CHF was added to the balance of Default wallet", $transactions[2]->toString() ); $this->assertSame( "Payment", $transactions[2]->shortDescription() ); $this->assertSame(12, $transactions[3]->amount); $this->assertSame(Transaction::WALLET_AWARD, $transactions[3]->type); $this->assertSame( "Bonus of 0,12 CHF awarded to Default wallet; A test award", $transactions[3]->toString() ); $this->assertSame( "Bonus: A test award", $transactions[3]->shortDescription() ); $ent = $transactions[4]->entitlement(); $this->assertSame(13, $transactions[4]->amount); $this->assertSame(Transaction::ENTITLEMENT_CREATED, $transactions[4]->type); $this->assertSame( "test@test.com created mailbox for " . $ent->entitleableTitle(), $transactions[4]->toString() ); $this->assertSame( "Added mailbox for " . $ent->entitleableTitle(), $transactions[4]->shortDescription() ); $ent = $transactions[5]->entitlement(); $this->assertSame(14, $transactions[5]->amount); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $transactions[5]->type); $this->assertSame( sprintf("%s for %s is billed at 0,14 CHF", $ent->sku->title, $ent->entitleableTitle()), $transactions[5]->toString() ); $this->assertSame( sprintf("Billed %s for %s", $ent->sku->title, $ent->entitleableTitle()), $transactions[5]->shortDescription() ); $ent = $transactions[6]->entitlement(); $this->assertSame(15, $transactions[6]->amount); $this->assertSame(Transaction::ENTITLEMENT_DELETED, $transactions[6]->type); $this->assertSame( sprintf("test@test.com deleted %s for %s", $ent->sku->title, $ent->entitleableTitle()), $transactions[6]->toString() ); $this->assertSame( sprintf("Deleted %s for %s", $ent->sku->title, $ent->entitleableTitle()), $transactions[6]->shortDescription() ); } /** * Test that an exception is being thrown on invalid type */ public function testInvalidType(): void { $this->expectException(\Exception::class); $transaction = Transaction::create( [ 'object_id' => 'fake-id', 'object_type' => Wallet::class, 'type' => 'invalid', 'amount' => 9 ] ); } public function testEntitlementForWallet(): void { $transaction = \App\Transaction::where('object_type', \App\Wallet::class) ->whereIn('object_id', \App\Wallet::pluck('id'))->first(); $entitlement = $transaction->entitlement(); $this->assertNull($entitlement); $this->assertNotNull($transaction->wallet()); } public function testWalletForEntitlement(): void { $transaction = \App\Transaction::where('object_type', \App\Entitlement::class) ->whereIn('object_id', \App\Entitlement::pluck('id'))->first(); $wallet = $transaction->wallet(); $this->assertNull($wallet); $this->assertNotNull($transaction->entitlement()); } }