diff --git a/src/app/Documents/Receipt.php b/src/app/Documents/Receipt.php index 22b65619..4c2d1927 100644 --- a/src/app/Documents/Receipt.php +++ b/src/app/Documents/Receipt.php @@ -1,263 +1,271 @@ wallet = $wallet; $this->year = $year; $this->month = $month; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'pdf') * * @return string HTML or PDF output */ public static function fakeRender(string $type = 'html'): string { $wallet = new Wallet(); $wallet->id = \App\Utils::uuidStr(); $wallet->owner = new User(['id' => 123456789]); // @phpstan-ignore-line $receipt = new self($wallet, date('Y'), date('n')); self::$fakeMode = true; if ($type == 'pdf') { return $receipt->pdfOutput(); } elseif ($type !== 'html') { throw new \Exception("Unsupported output format"); } return $receipt->htmlOutput(); } /** * Render the receipt in HTML format. * * @return string HTML content */ public function htmlOutput(): string { return $this->build()->render(); } /** * Render the receipt in PDF format. * * @return string PDF content */ public function pdfOutput(): string { // Parse ther HTML template $html = $this->build()->render(); // Link fonts from public/fonts to storage/fonts so DomPdf can find them if (!is_link(storage_path('fonts/Roboto-Regular.ttf'))) { symlink( public_path('fonts/Roboto-Regular.ttf'), storage_path('fonts/Roboto-Regular.ttf') ); symlink( public_path('fonts/Roboto-Bold.ttf'), storage_path('fonts/Roboto-Bold.ttf') ); } // Fix font and image paths $html = str_replace('url(/fonts/', 'url(fonts/', $html); $html = str_replace('src="/images/', 'src="images/', $html); // TODO: The output file is about ~200KB, we could probably slim it down // by using separate font files with small subset of languages when // there are no Unicode characters used, e.g. only ASCII or Latin. // Load PDF generator $pdf = \PDF::loadHTML($html)->setPaper('a4', 'portrait'); return $pdf->output(); } /** * Build the document * * @return \Illuminate\View\View The template object */ protected function build() { $appName = \config('app.name'); $start = Carbon::create($this->year, $this->month, 1, 0, 0, 0); $end = $start->copy()->endOfMonth(); $month = \trans('documents.month' . intval($this->month)); $title = \trans('documents.receipt-title', ['year' => $this->year, 'month' => $month]); $company = $this->companyData(); if (self::$fakeMode) { $country = 'CH'; $customer = [ 'id' => $this->wallet->owner->id, 'wallet_id' => $this->wallet->id, 'customer' => 'Freddie Krüger
7252 Westminster Lane
Forest Hills, NY 11375', ]; $items = collect([ (object) [ 'amount' => 1234, 'updated_at' => $start->copy()->next(Carbon::MONDAY), ], (object) [ 'amount' => 10000, 'updated_at' => $start->copy()->next()->next(), ], (object) [ 'amount' => 1234, 'updated_at' => $start->copy()->next()->next()->next(Carbon::MONDAY), ], (object) [ 'amount' => 99, 'updated_at' => $start->copy()->next()->next()->next(), ], ]); } else { $customer = $this->customerData(); $country = $this->wallet->owner->getSetting('country'); $items = $this->wallet->payments() ->where('status', PaymentProvider::STATUS_PAID) ->where('updated_at', '>=', $start) ->where('updated_at', '<', $end) - ->where('amount', '>', 0) + ->where('amount', '<>', 0) ->orderBy('updated_at') ->get(); } $vatRate = \config('app.vat.rate'); $vatCountries = explode(',', \config('app.vat.countries')); $vatCountries = array_map('strtoupper', array_map('trim', $vatCountries)); if (!$country || !in_array(strtoupper($country), $vatCountries)) { $vatRate = 0; } $totalVat = 0; $total = 0; $items = $items->map(function ($item) use (&$total, &$totalVat, $appName, $vatRate) { $amount = $item->amount; if ($vatRate > 0) { $amount = round($amount * ((100 - $vatRate) / 100)); $totalVat += $item->amount - $amount; } $total += $amount; + if ($item->type == PaymentProvider::TYPE_REFUND) { + $description = \trans('documents.receipt-refund'); + } elseif ($item->type == PaymentProvider::TYPE_CHARGEBACK) { + $description = \trans('documents.receipt-chargeback'); + } else { + $description = \trans('documents.receipt-item-desc', ['site' => $appName]); + } + return [ 'amount' => $this->wallet->money($amount), - 'description' => \trans('documents.receipt-item-desc', ['site' => $appName]), + 'description' => $description, 'date' => $item->updated_at->toDateString(), ]; }); // Load the template $view = view('documents.receipt') ->with([ 'site' => $appName, 'title' => $title, 'company' => $company, 'customer' => $customer, 'items' => $items, 'subTotal' => $this->wallet->money($total), 'total' => $this->wallet->money($total + $totalVat), 'totalVat' => $this->wallet->money($totalVat), 'vatRate' => preg_replace('/([.,]00|0|[.,])$/', '', sprintf('%.2f', $vatRate)), 'vat' => $vatRate > 0, ]); return $view; } /** * Prepare customer data for the template * * @return array Customer data for the template */ protected function customerData(): array { $user = $this->wallet->owner; $name = $user->name(); $organization = $user->getSetting('organization'); $address = $user->getSetting('billing_address'); $customer = trim(($organization ?: $name) . "\n$address"); $customer = str_replace("\n", '
', htmlentities($customer)); return [ 'id' => $this->wallet->owner->id, 'wallet_id' => $this->wallet->id, 'customer' => $customer, ]; } /** * Prepare company data for the template * * @return array Company data for the template */ protected function companyData(): array { $header = \config('app.company.name') . "\n" . \config('app.company.address'); $header = str_replace("\n", '
', htmlentities($header)); $footerLineLength = 110; $footer = \config('app.company.details'); $contact = \config('app.company.email'); $logo = \config('app.company.logo'); if ($contact) { $length = strlen($footer) + strlen($contact) + 3; $contact = htmlentities($contact); $footer .= ($length > $footerLineLength ? "\n" : ' | ') . sprintf('%s', $contact, $contact); } return [ 'logo' => $logo ? "" : '', 'header' => $header, 'footer' => $footer, ]; } } diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php index a8ab8db6..e15a913b 100644 --- a/src/app/Http/Controllers/API/V4/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/WalletsController.php @@ -1,322 +1,322 @@ 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) + ->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; if (in_array($item->type, [Transaction::WALLET_PENALTY, Transaction::WALLET_DEBIT])) { $amount *= -1; } $entry = [ 'id' => $item->id, 'createdAt' => $item->created_at->format('Y-m-d H:i'), 'type' => $item->type, 'description' => $item->shortDescription(), 'amount' => $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'); } $params = [ 'date' => $until->toDateString(), 'days' => Carbon::now()->diffForHumans($until, Carbon::DIFF_ABSOLUTE), ]; return \trans('app.wallet-notice-date', $params); } return null; } } diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php index c6332eb9..1dc2f4b6 100644 --- a/src/app/Providers/Payment/Mollie.php +++ b/src/app/Providers/Payment/Mollie.php @@ -1,461 +1,488 @@ tag */ public function customerLink(Wallet $wallet): ?string { $customer_id = self::mollieCustomerId($wallet, false); if (!$customer_id) { return null; } return sprintf( '%s', $customer_id, $customer_id ); } /** * Create a new auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents * - currency: The operation currency * - description: Operation desc. * * @return array Provider payment data: * - id: Operation identifier * - redirectUrl: the location to redirect to */ public function createMandate(Wallet $wallet, array $payment): ?array { // Register the user in Mollie, if not yet done $customer_id = self::mollieCustomerId($wallet, true); $request = [ 'amount' => [ 'currency' => $payment['currency'], 'value' => '0.00', ], 'customerId' => $customer_id, 'sequenceType' => 'first', 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'redirectUrl' => Utils::serviceUrl('/wallet'), 'locale' => 'en_US', // 'method' => 'creditcard', ]; // Create the payment in Mollie $response = mollie()->payments()->create($request); if ($response->mandateId) { $wallet->setSetting('mollie_mandate_id', $response->mandateId); } return [ 'id' => $response->id, 'redirectUrl' => $response->getCheckoutUrl(), ]; } /** * Revoke the auto-payment mandate for the wallet. * * @param \App\Wallet $wallet The wallet * * @return bool True on success, False on failure */ public function deleteMandate(Wallet $wallet): bool { // Get the Mandate info $mandate = self::mollieMandate($wallet); // Revoke the mandate on Mollie if ($mandate) { $mandate->revoke(); $wallet->setSetting('mollie_mandate_id', null); } return true; } /** * Get a auto-payment mandate for the wallet. * * @param \App\Wallet $wallet The wallet * * @return array|null Mandate information: * - id: Mandate identifier * - method: user-friendly payment method desc. * - isPending: the process didn't complete yet * - isValid: the mandate is valid */ public function getMandate(Wallet $wallet): ?array { // Get the Mandate info $mandate = self::mollieMandate($wallet); if (empty($mandate)) { return null; } $result = [ 'id' => $mandate->id, 'isPending' => $mandate->isPending(), 'isValid' => $mandate->isValid(), 'method' => self::paymentMethod($mandate, 'Unknown method') ]; return $result; } /** * Get a provider name * * @return string Provider name */ public function name(): string { return 'mollie'; } /** * Create a new payment. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents * - currency: The operation currency * - type: oneoff/recurring * - description: Operation desc. * * @return array Provider payment data: * - id: Operation identifier * - redirectUrl: the location to redirect to */ public function payment(Wallet $wallet, array $payment): ?array { if ($payment['type'] == self::TYPE_RECURRING) { return $this->paymentRecurring($wallet, $payment); } // Register the user in Mollie, if not yet done $customer_id = self::mollieCustomerId($wallet, true); // Note: Required fields: description, amount/currency, amount/value $request = [ 'amount' => [ 'currency' => $payment['currency'], // a number with two decimals is required 'value' => sprintf('%.2f', $payment['amount'] / 100), ], 'customerId' => $customer_id, 'sequenceType' => $payment['type'], 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', // 'method' => 'creditcard', 'redirectUrl' => Utils::serviceUrl('/wallet') // required for non-recurring payments ]; // TODO: Additional payment parameters for better fraud protection: // billingEmail - for bank transfers, Przelewy24, but not creditcard // billingAddress (it is a structured field not just text) // Create the payment in Mollie $response = mollie()->payments()->create($request); // Store the payment reference in database $payment['status'] = $response->status; $payment['id'] = $response->id; $this->storePayment($payment, $wallet->id); return [ 'id' => $payment['id'], 'redirectUrl' => $response->getCheckoutUrl(), ]; } /** * Create a new automatic payment operation. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data (see self::payment()) * * @return array Provider payment/session data: * - id: Operation identifier */ protected function paymentRecurring(Wallet $wallet, array $payment): ?array { // Check if there's a valid mandate $mandate = self::mollieMandate($wallet); if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) { return null; } $customer_id = self::mollieCustomerId($wallet, true); // Note: Required fields: description, amount/currency, amount/value $request = [ 'amount' => [ 'currency' => $payment['currency'], // a number with two decimals is required 'value' => sprintf('%.2f', $payment['amount'] / 100), ], 'customerId' => $customer_id, 'sequenceType' => $payment['type'], 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', // 'method' => 'creditcard', 'mandateId' => $mandate->id ]; // Create the payment in Mollie $response = mollie()->payments()->create($request); // Store the payment reference in database $payment['status'] = $response->status; $payment['id'] = $response->id; DB::beginTransaction(); $payment = $this->storePayment($payment, $wallet->id); // Mollie can return 'paid' status immediately, so we don't // have to wait for the webhook. What's more, the webhook would ignore // the payment because it will be marked as paid before the webhook. // Let's handle paid status here too. if ($response->isPaid()) { self::creditPayment($payment, $response); $notify = true; } elseif ($response->isFailed()) { // Note: I didn't find a way to get any description of the problem with a payment \Log::info(sprintf('Mollie payment failed (%s)', $response->id)); // Disable the mandate $wallet->setSetting('mandate_disabled', 1); $notify = true; } DB::commit(); if (!empty($notify)) { \App\Jobs\PaymentEmail::dispatch($payment); } return [ 'id' => $payment['id'], ]; } /** * Update payment status (and balance). * * @return int HTTP response code */ public function webhook(): int { $payment_id = \request()->input('id'); if (empty($payment_id)) { return 200; } $payment = Payment::find($payment_id); if (empty($payment)) { // Mollie recommends to return "200 OK" even if the payment does not exist return 200; } // Get the payment details from Mollie + // TODO: Consider https://github.com/mollie/mollie-api-php/issues/502 when it's fixed $mollie_payment = mollie()->payments()->get($payment_id); if (empty($mollie_payment)) { // Mollie recommends to return "200 OK" even if the payment does not exist return 200; } + $refunds = []; + if ($mollie_payment->isPaid()) { - if (!$mollie_payment->hasRefunds() && !$mollie_payment->hasChargebacks()) { - // The payment is paid and isn't refunded or charged back. - // Update the balance, if it wasn't already - if ($payment->status != self::STATUS_PAID && $payment->amount > 0) { - $credit = true; - $notify = $payment->type == self::TYPE_RECURRING; + // The payment is paid. Update the balance, and notify the user + if ($payment->status != self::STATUS_PAID && $payment->amount > 0) { + $credit = true; + $notify = $payment->type == self::TYPE_RECURRING; + } + + // The payment has been (partially) refunded. + // Let's process refunds with status "refunded". + if ($mollie_payment->hasRefunds()) { + foreach ($mollie_payment->refunds() as $refund) { + if ($refund->isTransferred() && $refund->amount->value) { + $refunds[] = [ + 'id' => $refund->id, + 'description' => $refund->description, + 'amount' => round(floatval($refund->amount->value) * 100), + 'type' => self::TYPE_REFUND, + // Note: we assume this is the original payment/wallet currency + ]; + } + } + } + + // The payment has been (partially) charged back. + // Let's process chargebacks (they have no states as refunds) + if ($mollie_payment->hasChargebacks()) { + foreach ($mollie_payment->chargebacks() as $chargeback) { + if ($chargeback->amount->value) { + $refunds[] = [ + 'id' => $chargeback->id, + 'amount' => round(floatval($chargeback->amount->value) * 100), + 'type' => self::TYPE_CHARGEBACK, + // Note: we assume this is the original payment/wallet currency + ]; + } } - } elseif ($mollie_payment->hasRefunds()) { - // The payment has been (partially) refunded. - // The status of the payment is still "paid" - // TODO: Update balance - } elseif ($mollie_payment->hasChargebacks()) { - // The payment has been (partially) charged back. - // The status of the payment is still "paid" - // TODO: Update balance } } elseif ($mollie_payment->isFailed()) { // Note: I didn't find a way to get any description of the problem with a payment \Log::info(sprintf('Mollie payment failed (%s)', $payment->id)); // Disable the mandate if ($payment->type == self::TYPE_RECURRING) { $notify = true; $payment->wallet->setSetting('mandate_disabled', 1); } } DB::beginTransaction(); // This is a sanity check, just in case the payment provider api // sent us open -> paid -> open -> paid. So, we lock the payment after // recivied a "final" state. $pending_states = [self::STATUS_OPEN, self::STATUS_PENDING, self::STATUS_AUTHORIZED]; if (in_array($payment->status, $pending_states)) { $payment->status = $mollie_payment->status; $payment->save(); } if (!empty($credit)) { self::creditPayment($payment, $mollie_payment); } + foreach ($refunds as $refund) { + $this->storeRefund($payment->wallet, $refund); + } + DB::commit(); if (!empty($notify)) { \App\Jobs\PaymentEmail::dispatch($payment); } return 200; } /** * Get Mollie customer identifier for specified wallet. * Create one if does not exist yet. * * @param \App\Wallet $wallet The wallet * @param bool $create Create the customer if does not exist yet * * @return ?string Mollie customer identifier */ protected static function mollieCustomerId(Wallet $wallet, bool $create = false): ?string { $customer_id = $wallet->getSetting('mollie_id'); // Register the user in Mollie if (empty($customer_id) && $create) { $customer = mollie()->customers()->create([ 'name' => $wallet->owner->name(), 'email' => $wallet->id . '@private.' . \config('app.domain'), ]); $customer_id = $customer->id; $wallet->setSetting('mollie_id', $customer->id); } return $customer_id; } /** * Get the active Mollie auto-payment mandate */ protected static function mollieMandate(Wallet $wallet) { $customer_id = $wallet->getSetting('mollie_id'); $mandate_id = $wallet->getSetting('mollie_mandate_id'); // Get the manadate reference we already have if ($customer_id && $mandate_id) { $mandate = mollie()->mandates()->getForId($customer_id, $mandate_id); if ($mandate) {// && ($mandate->isValid() || $mandate->isPending())) { return $mandate; } } // Get all mandates from Mollie and find the active one /* foreach ($customer->mandates() as $mandate) { if ($mandate->isValid() || $mandate->isPending()) { $wallet->setSetting('mollie_mandate_id', $mandate->id); return $mandate; } } */ } /** * Apply the successful payment's pecunia to the wallet */ protected static function creditPayment($payment, $mollie_payment) { // Extract the payment method for transaction description $method = self::paymentMethod($mollie_payment, 'Mollie'); // TODO: Localization? $description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment'; $description .= " transaction {$payment->id} using {$method}"; $payment->wallet->credit($payment->amount, $description); // Unlock the disabled auto-payment mandate if ($payment->wallet->balance >= 0) { $payment->wallet->setSetting('mandate_disabled', null); } } /** * Extract payment method description from Mollie payment/mandate details */ protected static function paymentMethod($object, $default = ''): string { $details = $object->details; // Mollie supports 3 methods here switch ($object->method) { case 'creditcard': // If the customer started, but never finished the 'first' payment // card details will be empty, and mandate will be 'pending'. if (empty($details->cardNumber)) { return 'Credit Card'; } return sprintf( '%s (**** **** **** %s)', $details->cardLabel ?: 'Card', // @phpstan-ignore-line $details->cardNumber ); case 'directdebit': return sprintf('Direct Debit (%s)', $details->customerAccount); case 'paypal': return sprintf('PayPal (%s)', $details->consumerAccount); } return $default; } } diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php index 991169bd..67dea4b7 100644 --- a/src/app/Providers/PaymentProvider.php +++ b/src/app/Providers/PaymentProvider.php @@ -1,156 +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'], + '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 d504e468..08138ff4 100644 --- a/src/app/Transaction.php +++ b/src/app/Transaction.php @@ -1,191 +1,197 @@ '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'; - return \trans("transactions.{$label}", $this->descriptionParams()); + $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'}, ]; 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); 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/resources/lang/en/documents.php b/src/resources/lang/en/documents.php index ca8e3db4..e6d5986a 100644 --- a/src/resources/lang/en/documents.php +++ b/src/resources/lang/en/documents.php @@ -1,38 +1,40 @@ "Account ID", 'amount' => "Amount", 'customer-no' => "Customer No.", 'date' => "Date", 'description' => "Description", 'period' => "Period", 'total' => "Total", 'month1' => "January", 'month2' => "February", 'month3' => "March", 'month4' => "April", 'month5' => "May", 'month6' => "June", 'month7' => "July", 'month8' => "August", 'month9' => "September", 'month10' => "October", 'month11' => "November", 'month12' => "December", 'receipt-filename' => ":site Receipt for :id", 'receipt-title' => "Receipt for :month :year", 'receipt-item-desc' => ":site Services", + 'receipt-refund' => "Refund", + 'receipt-chargeback' => "Chargeback", 'subtotal' => "Subtotal", 'vat' => "VAT (:rate%)", ]; diff --git a/src/resources/lang/en/transactions.php b/src/resources/lang/en/transactions.php index d0248a51..21b193dd 100644 --- a/src/resources/lang/en/transactions.php +++ b/src/resources/lang/en/transactions.php @@ -1,21 +1,26 @@ ':user_email created :sku_title for :object', 'entitlement-billed' => ':sku_title for :object is billed at :amount', 'entitlement-deleted' => ':user_email deleted :sku_title for :object', + 'entitlement-created-short' => 'Added :sku_title for :object', + 'entitlement-billed-short' => 'Billed :sku_title for :object', + 'entitlement-deleted-short' => 'Deleted :sku_title for :object', + 'wallet-award' => 'Bonus of :amount awarded to :wallet; :description', + 'wallet-chargeback' => ':amount was charged back from :wallet', 'wallet-credit' => ':amount was added to the balance of :wallet', 'wallet-debit' => ':amount was deducted from the balance of :wallet', 'wallet-penalty' => 'The balance of :wallet was reduced by :amount; :description', - - 'entitlement-created-short' => 'Added :sku_title for :object', - 'entitlement-billed-short' => 'Billed :sku_title for :object', - 'entitlement-deleted-short' => 'Deleted :sku_title for :object', + 'wallet-refund' => ':amount was refunded from the balance of :wallet', + 'wallet-refund' => ':amount was refunded from :wallet', 'wallet-award-short' => 'Bonus: :description', + 'wallet-chargeback-short' => 'Chargeback', 'wallet-credit-short' => 'Payment', 'wallet-debit-short' => 'Deduction', 'wallet-penalty-short' => 'Charge: :description', + 'wallet-refund-short' => 'Refund: :description', ]; diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php index f532fc03..6d28689a 100644 --- a/src/tests/Feature/Controller/PaymentsMollieTest.php +++ b/src/tests/Feature/Controller/PaymentsMollieTest.php @@ -1,595 +1,758 @@ '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(); - Transaction::where('object_id', $wallet->id) - ->where('type', Transaction::WALLET_CREDIT)->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(); - Transaction::where('object_id', $wallet->id) - ->where('type', Transaction::WALLET_CREDIT)->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'); // 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 (invalid input) $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 (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']); // 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); // 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' => 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']); $wallet = $user->wallets()->first(); $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); $this->assertEquals(1, $wallet->getSetting('mandate_balance')); // 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 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 $this->createMandate($wallet, ['amount' => 20.10, 'balance' => 10]); // Expect a recurring payment as we have a valid mandate at this point $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 first payment and another for // the recurring payment $this->assertCount(1, $wallet->payments()->get()); $payment = $wallet->payments()->first(); $this->assertSame(2010, $payment->amount); // 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(1, $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(1, $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(1, $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(1, $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; }); - $responseStack = $this->unmockMollie(); + $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(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(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/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php index 25291f9f..3f2fa1f5 100644 --- a/src/tests/Feature/Controller/WalletsTest.php +++ b/src/tests/Feature/Controller/WalletsTest.php @@ -1,336 +1,337 @@ deleteTestUser('wallets-controller@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallets-controller@kolabnow.com'); parent::tearDown(); } /** * Test for getWalletNotice() method */ public function testGetWalletNotice(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $controller = new WalletsController(); $method = new \ReflectionMethod($controller, 'getWalletNotice'); $method->setAccessible(true); // User/entitlements created today, balance=0 $notice = $method->invoke($controller, $wallet); $this->assertSame('You are in your free trial period.', $notice); $wallet->owner->created_at = Carbon::now()->subDays(15); $wallet->owner->save(); $notice = $method->invoke($controller, $wallet); $this->assertSame('Your free trial is about to end, top up to continue.', $notice); // User/entitlements created today, balance=-10 CHF $wallet->balance = -1000; $notice = $method->invoke($controller, $wallet); $this->assertSame('You are out of credit, top up your balance now.', $notice); // User/entitlements created slightly more than a month ago, balance=9,99 CHF (monthly) $wallet->owner->created_at = Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1); $wallet->owner->save(); $wallet->balance = 999; $notice = $method->invoke($controller, $wallet); $this->assertRegExp('/\((1 month|4 weeks)\)/', $notice); // Old entitlements, 100% discount $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); $discount = \App\Discount::where('discount', 100)->first(); $wallet->discount()->associate($discount); $notice = $method->invoke($controller, $wallet->refresh()); $this->assertSame(null, $notice); } /** * Test fetching pdf receipt */ public function testReceiptDownload(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); $response->assertStatus(403); // Invalid receipt id (current month) $receiptId = date('Y-m'); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(404); // Invalid receipt id $receiptId = '1000-03'; $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(404); // Valid receipt id $year = intval(date('Y')) - 1; $receiptId = "$year-12"; $filename = \config('app.name') . " Receipt for $year-12"; $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(200); $response->assertHeader('content-type', 'application/pdf'); $response->assertHeader('content-disposition', 'attachment; filename="' . $filename . '"'); $response->assertHeader('content-length'); $length = $response->headers->get('content-length'); $content = $response->content(); $this->assertStringStartsWith("%PDF-1.", $content); $this->assertEquals(strlen($content), $length); } /** * Test fetching list of receipts */ public function testReceipts(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->payments()->delete(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(403); // Empty list expected $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); // Insert a payment to the database $date = Carbon::create(intval(date('Y')) - 1, 4, 30); $payment = Payment::create([ 'id' => 'AAA1', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Paid in April', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, ]); $payment->updated_at = $date; $payment->save(); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([$date->format('Y-m')], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(1, $json['count']); $this->assertSame(false, $json['hasMore']); } /** * Test fetching a wallet (GET /api/v4/wallets/:id) */ public function testShow(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $wallet = $john->wallets()->first(); // Accessing a wallet of someone else $response = $this->actingAs($jack)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(403); // Accessing non-existing wallet $response = $this->actingAs($jack)->get("api/v4/wallets/aaa"); $response->assertStatus(404); // Wallet owner $response = $this->actingAs($john)->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->assertTrue(empty($json['description'])); + // TODO: This assertion does not work after a longer while from seeding $this->assertTrue(!empty($json['notice'])); } /** * Test fetching wallet transactions */ public function testTransactions(): void { $package_kolab = \App\Package::where('title', 'kolab')->first(); $user = $this->getTestUser('wallets-controller@kolabnow.com'); $user->assignPackage($package_kolab); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); // Expect empty list $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); // Create some sample transactions $transactions = $this->createTestTransactions($wallet); $transactions = array_reverse($transactions); $pages = array_chunk($transactions, 10 /* page size*/); // Get the first page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(10, $json['count']); $this->assertSame(true, $json['hasMore']); $this->assertCount(10, $json['list']); foreach ($pages[0] 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']); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); } $search = null; // Get the second page $response = $this->actingAs($user)->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->assertSame( $transaction->type == Transaction::WALLET_DEBIT, $json['list'][$idx]['hasDetails'] ); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); if ($transaction->type == Transaction::WALLET_DEBIT) { $search = $transaction->id; } } // Get a non-existing page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=3"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(3, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(0, $json['list']); // Sub-transaction searching $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction=123"); $response->assertStatus(404); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][0]['type']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][1]['type']); // Test that John gets 404 if he tries to access // someone else's transaction ID on his wallet's endpoint $wallet = $john->wallets()->first(); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(404); } } diff --git a/src/tests/Feature/Documents/ReceiptTest.php b/src/tests/Feature/Documents/ReceiptTest.php index 3a815b09..e0fc5726 100644 --- a/src/tests/Feature/Documents/ReceiptTest.php +++ b/src/tests/Feature/Documents/ReceiptTest.php @@ -1,323 +1,367 @@ paymentIDs)->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('receipt-test@kolabnow.com'); Payment::whereIn('id', $this->paymentIDs)->delete(); parent::tearDown(); } /** * Test receipt HTML output (without VAT) */ public function testHtmlOutput(): void { $appName = \config('app.name'); $wallet = $this->getTestData(); $receipt = new Receipt($wallet, 2020, 5); $html = $receipt->htmlOutput(); $this->assertStringStartsWith('', $html); $dom = new \DOMDocument('1.0', 'UTF-8'); $dom->loadHTML($html); // Title $title = $dom->getElementById('title'); $this->assertSame("Receipt for May 2020", $title->textContent); // Company name/address $header = $dom->getElementById('header'); $companyOutput = $this->getNodeContent($header->getElementsByTagName('td')[0]); $companyExpected = \config('app.company.name') . "\n" . \config('app.company.address'); $this->assertSame($companyExpected, $companyOutput); // The main table content $content = $dom->getElementById('content'); $records = $content->getElementsByTagName('tr'); - $this->assertCount(5, $records); + $this->assertCount(7, $records); $headerCells = $records[0]->getElementsByTagName('th'); $this->assertCount(3, $headerCells); $this->assertSame('Date', $this->getNodeContent($headerCells[0])); $this->assertSame('Description', $this->getNodeContent($headerCells[1])); $this->assertSame('Amount', $this->getNodeContent($headerCells[2])); $cells = $records[1]->getElementsByTagName('td'); $this->assertCount(3, $cells); $this->assertSame('2020-05-01', $this->getNodeContent($cells[0])); $this->assertSame("$appName Services", $this->getNodeContent($cells[1])); $this->assertSame('12,34 CHF', $this->getNodeContent($cells[2])); $cells = $records[2]->getElementsByTagName('td'); $this->assertCount(3, $cells); $this->assertSame('2020-05-10', $this->getNodeContent($cells[0])); $this->assertSame("$appName Services", $this->getNodeContent($cells[1])); $this->assertSame('0,01 CHF', $this->getNodeContent($cells[2])); $cells = $records[3]->getElementsByTagName('td'); $this->assertCount(3, $cells); - $this->assertSame('2020-05-31', $this->getNodeContent($cells[0])); + $this->assertSame('2020-05-21', $this->getNodeContent($cells[0])); $this->assertSame("$appName Services", $this->getNodeContent($cells[1])); $this->assertSame('1,00 CHF', $this->getNodeContent($cells[2])); - $summaryCells = $records[4]->getElementsByTagName('td'); + $cells = $records[4]->getElementsByTagName('td'); + $this->assertCount(3, $cells); + $this->assertSame('2020-05-30', $this->getNodeContent($cells[0])); + $this->assertSame("Refund", $this->getNodeContent($cells[1])); + $this->assertSame('-1,00 CHF', $this->getNodeContent($cells[2])); + $cells = $records[5]->getElementsByTagName('td'); + $this->assertCount(3, $cells); + $this->assertSame('2020-05-31', $this->getNodeContent($cells[0])); + $this->assertSame("Chargeback", $this->getNodeContent($cells[1])); + $this->assertSame('-0,10 CHF', $this->getNodeContent($cells[2])); + $summaryCells = $records[6]->getElementsByTagName('td'); $this->assertCount(2, $summaryCells); $this->assertSame('Total', $this->getNodeContent($summaryCells[0])); - $this->assertSame('13,35 CHF', $this->getNodeContent($summaryCells[1])); + $this->assertSame('12,25 CHF', $this->getNodeContent($summaryCells[1])); // Customer data $customer = $dom->getElementById('customer'); $customerCells = $customer->getElementsByTagName('td'); $customerOutput = $this->getNodeContent($customerCells[0]); $customerExpected = "Firstname Lastname\nTest Unicode Straße 150\n10115 Berlin"; $this->assertSame($customerExpected, $this->getNodeContent($customerCells[0])); $customerIdents = $this->getNodeContent($customerCells[1]); //$this->assertTrue(strpos($customerIdents, "Account ID {$wallet->id}") !== false); $this->assertTrue(strpos($customerIdents, "Customer No. {$wallet->owner->id}") !== false); // Company details in the footer $footer = $dom->getElementById('footer'); $footerOutput = $footer->textContent; $this->assertStringStartsWith(\config('app.company.details'), $footerOutput); $this->assertTrue(strpos($footerOutput, \config('app.company.email')) !== false); } /** * Test receipt HTML output (with VAT) */ public function testHtmlOutputVat(): void { \config(['app.vat.rate' => 7.7]); \config(['app.vat.countries' => 'ch']); $appName = \config('app.name'); $wallet = $this->getTestData('CH'); $receipt = new Receipt($wallet, 2020, 5); $html = $receipt->htmlOutput(); $this->assertStringStartsWith('', $html); $dom = new \DOMDocument('1.0', 'UTF-8'); $dom->loadHTML($html); // The main table content $content = $dom->getElementById('content'); $records = $content->getElementsByTagName('tr'); - $this->assertCount(7, $records); + $this->assertCount(9, $records); $cells = $records[1]->getElementsByTagName('td'); $this->assertCount(3, $cells); $this->assertSame('2020-05-01', $this->getNodeContent($cells[0])); $this->assertSame("$appName Services", $this->getNodeContent($cells[1])); $this->assertSame('11,39 CHF', $this->getNodeContent($cells[2])); $cells = $records[2]->getElementsByTagName('td'); $this->assertCount(3, $cells); $this->assertSame('2020-05-10', $this->getNodeContent($cells[0])); $this->assertSame("$appName Services", $this->getNodeContent($cells[1])); $this->assertSame('0,01 CHF', $this->getNodeContent($cells[2])); $cells = $records[3]->getElementsByTagName('td'); $this->assertCount(3, $cells); - $this->assertSame('2020-05-31', $this->getNodeContent($cells[0])); + $this->assertSame('2020-05-21', $this->getNodeContent($cells[0])); $this->assertSame("$appName Services", $this->getNodeContent($cells[1])); $this->assertSame('0,92 CHF', $this->getNodeContent($cells[2])); - $subtotalCells = $records[4]->getElementsByTagName('td'); + $cells = $records[4]->getElementsByTagName('td'); + $this->assertCount(3, $cells); + $this->assertSame('2020-05-30', $this->getNodeContent($cells[0])); + $this->assertSame("Refund", $this->getNodeContent($cells[1])); + $this->assertSame('-0,92 CHF', $this->getNodeContent($cells[2])); + $cells = $records[5]->getElementsByTagName('td'); + $this->assertCount(3, $cells); + $this->assertSame('2020-05-31', $this->getNodeContent($cells[0])); + $this->assertSame("Chargeback", $this->getNodeContent($cells[1])); + $this->assertSame('-0,09 CHF', $this->getNodeContent($cells[2])); + $subtotalCells = $records[6]->getElementsByTagName('td'); $this->assertCount(2, $subtotalCells); $this->assertSame('Subtotal', $this->getNodeContent($subtotalCells[0])); - $this->assertSame('12,32 CHF', $this->getNodeContent($subtotalCells[1])); - $vatCells = $records[5]->getElementsByTagName('td'); + $this->assertSame('11,31 CHF', $this->getNodeContent($subtotalCells[1])); + $vatCells = $records[7]->getElementsByTagName('td'); $this->assertCount(2, $vatCells); $this->assertSame('VAT (7.7%)', $this->getNodeContent($vatCells[0])); - $this->assertSame('1,03 CHF', $this->getNodeContent($vatCells[1])); - $totalCells = $records[6]->getElementsByTagName('td'); + $this->assertSame('0,94 CHF', $this->getNodeContent($vatCells[1])); + $totalCells = $records[8]->getElementsByTagName('td'); $this->assertCount(2, $totalCells); $this->assertSame('Total', $this->getNodeContent($totalCells[0])); - $this->assertSame('13,35 CHF', $this->getNodeContent($totalCells[1])); + $this->assertSame('12,25 CHF', $this->getNodeContent($totalCells[1])); } /** * Test receipt PDF output */ public function testPdfOutput(): void { $wallet = $this->getTestData(); $receipt = new Receipt($wallet, 2020, 5); $pdf = $receipt->PdfOutput(); $this->assertStringStartsWith("%PDF-1.", $pdf); $this->assertTrue(strlen($pdf) > 5000); // TODO: Test the content somehow } /** * Prepare data for a test * * @param string $country User country code * * @return \App\Wallet */ protected function getTestData(string $country = null): Wallet { Bus::fake(); $user = $this->getTestUser('receipt-test@kolabnow.com'); $user->setSettings([ 'first_name' => 'Firstname', 'last_name' => 'Lastname', 'billing_address' => "Test Unicode Straße 150\n10115 Berlin", 'country' => $country ]); $wallet = $user->wallets()->first(); // Create two payments out of the 2020-05 period // and three in it, plus one in the period but unpaid, - // and one with amount 0 + // and one with amount 0, and an extra refund and chanrgeback $payment = Payment::create([ 'id' => 'AAA1', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Paid in April', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, ]); $payment->updated_at = Carbon::create(2020, 4, 30, 12, 0, 0); $payment->save(); $payment = Payment::create([ 'id' => 'AAA2', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Paid in June', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 2222, ]); $payment->updated_at = Carbon::create(2020, 6, 1, 0, 0, 0); $payment->save(); $payment = Payment::create([ 'id' => 'AAA3', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Auto-Payment Setup', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 0, ]); $payment->updated_at = Carbon::create(2020, 5, 1, 0, 0, 0); $payment->save(); $payment = Payment::create([ 'id' => 'AAA4', 'status' => PaymentProvider::STATUS_OPEN, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Payment not yet paid', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 999, ]); $payment->updated_at = Carbon::create(2020, 5, 1, 0, 0, 0); $payment->save(); - // ... so we expect the last three on the receipt + // ... so we expect the five three on the receipt $payment = Payment::create([ 'id' => 'AAA5', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Payment OK', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1234, ]); $payment->updated_at = Carbon::create(2020, 5, 1, 0, 0, 0); $payment->save(); $payment = Payment::create([ 'id' => 'AAA6', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Payment OK', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1, ]); $payment->updated_at = Carbon::create(2020, 5, 10, 0, 0, 0); $payment->save(); $payment = Payment::create([ 'id' => 'AAA7', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_RECURRING, 'description' => 'Payment OK', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 100, ]); + $payment->updated_at = Carbon::create(2020, 5, 21, 23, 59, 0); + $payment->save(); + + $payment = Payment::create([ + 'id' => 'ref1', + 'status' => PaymentProvider::STATUS_PAID, + 'type' => PaymentProvider::TYPE_REFUND, + 'description' => 'refund desc', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => -100, + ]); + $payment->updated_at = Carbon::create(2020, 5, 30, 23, 59, 0); + $payment->save(); + + $payment = Payment::create([ + 'id' => 'chback1', + 'status' => PaymentProvider::STATUS_PAID, + 'type' => PaymentProvider::TYPE_CHARGEBACK, + 'description' => '', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => -10, + ]); $payment->updated_at = Carbon::create(2020, 5, 31, 23, 59, 0); $payment->save(); // Make sure some config is set so we can test it's put into the receipt if (empty(\config('app.company.name'))) { \config(['app.company.name' => 'Company Co.']); } if (empty(\config('app.company.email'))) { \config(['app.company.email' => 'email@domina.tld']); } if (empty(\config('app.company.details'))) { \config(['app.company.details' => 'VAT No. 123456789']); } if (empty(\config('app.company.address'))) { \config(['app.company.address' => "Test Street 12\n12345 Some Place"]); } return $wallet; } /** * Extract text from a HTML element replacing
with \n * * @param \DOMElement $node The HTML element * * @return string The content */ protected function getNodeContent(\DOMElement $node) { $content = []; foreach ($node->childNodes as $child) { if ($child->nodeName == 'br') { $content[] = "\n"; } else { $content[] = $child->textContent; } } return trim(implode($content)); } }