Page MenuHomePhorge

D1762.1775153333.diff
No OneTemporary

Authored By
Unknown
Size
24 KB
Referenced Files
None
Subscribers
None

D1762.1775153333.diff

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/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();

File Metadata

Mime Type
text/plain
Expires
Thu, Apr 2, 6:08 PM (18 h, 55 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18820207
Default Alt Text
D1762.1775153333.diff (24 KB)

Event Timeline