diff --git a/src/app/Documents/Receipt.php b/src/app/Documents/Receipt.php --- a/src/app/Documents/Receipt.php +++ b/src/app/Documents/Receipt.php @@ -160,7 +160,7 @@ ->where('status', PaymentProvider::STATUS_PAID) ->where('updated_at', '>=', $start) ->where('updated_at', '<', $end) - ->where('amount', '>', 0) + ->where('amount', '<>', 0) ->orderBy('updated_at') ->get(); } @@ -185,9 +185,17 @@ $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(), ]; }); diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php --- a/src/app/Http/Controllers/API/V4/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/WalletsController.php @@ -179,7 +179,7 @@ $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 diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php --- a/src/app/Providers/Payment/Mollie.php +++ b/src/app/Providers/Payment/Mollie.php @@ -293,6 +293,7 @@ } // 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)) { @@ -300,22 +301,44 @@ 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 @@ -343,6 +366,10 @@ self::creditPayment($payment, $mollie_payment); } + foreach ($refunds as $refund) { + $this->storeRefund($payment->wallet, $refund); + } + DB::commit(); if (!empty($notify)) { diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php --- a/src/app/Providers/PaymentProvider.php +++ b/src/app/Providers/PaymentProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Transaction; use App\Payment; use App\Wallet; @@ -18,6 +19,8 @@ public const TYPE_ONEOFF = 'oneoff'; public const TYPE_RECURRING = 'recurring'; public const TYPE_MANDATE = 'mandate'; + public const TYPE_REFUND = 'refund'; + public const TYPE_CHARGEBACK = 'chargeback'; /** const int Minimum amount of money in a single payment (in cents) */ public const MIN_AMOUNT = 1000; @@ -153,4 +156,42 @@ 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 --- a/src/app/Transaction.php +++ b/src/app/Transaction.php @@ -28,6 +28,8 @@ public const WALLET_CREDIT = 'credit'; public const WALLET_DEBIT = 'debit'; public const WALLET_PENALTY = 'penalty'; + public const WALLET_REFUND = 'refund'; + public const WALLET_CHARGEBACK = 'chback'; protected $fillable = [ // actor, if any @@ -94,6 +96,8 @@ 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; @@ -112,7 +116,9 @@ { $label = $this->objectTypeToLabelString() . '-' . $this->{'type'} . '-short'; - return \trans("transactions.{$label}", $this->descriptionParams()); + $result = \trans("transactions.{$label}", $this->descriptionParams()); + + return trim($result, ': '); } /** diff --git a/src/resources/lang/en/documents.php b/src/resources/lang/en/documents.php --- a/src/resources/lang/en/documents.php +++ b/src/resources/lang/en/documents.php @@ -32,6 +32,8 @@ '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 --- a/src/resources/lang/en/transactions.php +++ b/src/resources/lang/en/transactions.php @@ -5,17 +5,22 @@ '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 --- a/src/tests/Feature/Controller/PaymentsMollieTest.php +++ b/src/tests/Feature/Controller/PaymentsMollieTest.php @@ -34,8 +34,12 @@ 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(); } /** @@ -48,8 +52,12 @@ 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(); } @@ -569,7 +577,162 @@ 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(); } /** diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php --- a/src/tests/Feature/Controller/WalletsTest.php +++ b/src/tests/Feature/Controller/WalletsTest.php @@ -209,6 +209,7 @@ $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'])); } diff --git a/src/tests/Feature/Documents/ReceiptTest.php b/src/tests/Feature/Documents/ReceiptTest.php --- a/src/tests/Feature/Documents/ReceiptTest.php +++ b/src/tests/Feature/Documents/ReceiptTest.php @@ -62,7 +62,7 @@ // 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); @@ -81,13 +81,23 @@ $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'); @@ -127,7 +137,7 @@ // 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); @@ -141,21 +151,31 @@ $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])); } /** @@ -196,7 +216,7 @@ // 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', @@ -246,7 +266,7 @@ $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, @@ -280,6 +300,30 @@ '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();