Page MenuHomePhorge

D1030.1775190602.diff
No OneTemporary

Authored By
Unknown
Size
28 KB
Referenced Files
None
Subscribers
None

D1030.1775190602.diff

diff --git a/src/.env.example b/src/.env.example
--- a/src/.env.example
+++ b/src/.env.example
@@ -3,6 +3,7 @@
APP_KEY=
APP_DEBUG=true
APP_URL=http://127.0.0.1:8000
+APP_PUBLIC_URL=
APP_DOMAIN=kolabnow.com
LOG_CHANNEL=stack
@@ -54,6 +55,8 @@
SWOOLE_HTTP_HOST=127.0.0.1
SWOOLE_HTTP_PORT=8000
+MOLLIE_KEY=
+
MAIL_DRIVER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
diff --git a/src/app/Console/Commands/WalletCharge.php b/src/app/Console/Commands/WalletCharge.php
--- a/src/app/Console/Commands/WalletCharge.php
+++ b/src/app/Console/Commands/WalletCharge.php
@@ -52,6 +52,11 @@
);
$wallet->chargeEntitlements();
+
+ if ($wallet->balance < 0) {
+ // Disabled for now
+ // \App\Jobs\WalletPayment::dispatch($wallet);
+ }
}
}
}
diff --git a/src/app/Http/Controllers/API/PaymentsController.php b/src/app/Http/Controllers/API/PaymentsController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/PaymentsController.php
@@ -0,0 +1,221 @@
+<?php
+
+namespace App\Http\Controllers\API;
+
+use App\Payment;
+use App\Wallet;
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Validator;
+
+class PaymentsController extends Controller
+{
+ /**
+ * Create a new payment.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function store(Request $request)
+ {
+ $current_user = Auth::guard()->user();
+
+ // TODO: Wallet selection
+ $wallet = $current_user->wallets()->first();
+
+ // Check required fields
+ $v = Validator::make(
+ $request->all(),
+ [
+ 'amount' => 'required|int|min:1',
+ ]
+ );
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ // Register the user in Mollie, if not yet done
+ // FIXME: Maybe Mollie ID should be bound to a wallet, but then
+ // The same customer could technicly have multiple
+ // Mollie IDs, then we'd need to use some "virtual" email
+ // address (e.g. <wallet-id>@<user-domain>) instead of the user email address
+ $customer_id = $current_user->getSetting('mollie_id');
+ $seq_type = 'oneoff';
+
+ if (empty($customer_id)) {
+ $customer = mollie()->customers()->create([
+ 'name' => $current_user->name,
+ 'email' => $current_user->email,
+ ]);
+
+ $seq_type = 'first';
+ $customer_id = $customer->id;
+ $current_user->setSetting('mollie_id', $customer_id);
+ }
+
+ $payment_request = [
+ 'amount' => [
+ 'currency' => 'CHF',
+ // a number with two decimals is required
+ 'value' => sprintf('%.2f', $request->amount / 100),
+ ],
+ 'customerId' => $customer_id,
+ 'sequenceType' => $seq_type, // 'first' / 'oneoff' / 'recurring'
+ 'description' => 'Kolab Now Payment', // required
+ 'redirectUrl' => \url('/wallet'), // required for non-recurring payments
+ 'webhookUrl' => self::serviceUrl('/api/webhooks/payment/mollie'),
+ 'locale' => 'en_US',
+ ];
+
+ // Create the payment in Mollie
+ $payment = mollie()->payments()->create($payment_request);
+
+ // Store the payment reference in database
+ self::storePayment($payment, $wallet->id, $request->amount);
+
+ return response()->json([
+ 'status' => 'success',
+ 'redirectUrl' => $payment->getCheckoutUrl(),
+ ]);
+ }
+
+ /**
+ * Update payment status (and balance).
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\Response The response
+ */
+ public function webhook(Request $request)
+ {
+ $db_payment = Payment::find($request->id);
+
+ // Mollie recommends to return "200 OK" even if the payment does not exist
+ if (empty($db_payment)) {
+ return response('Success', 200);
+ }
+
+ // Get the payment details from Mollie
+ $payment = mollie()->payments()->get($request->id);
+
+ if (empty($payment)) {
+ return response('Success', 200);
+ }
+
+ if ($payment->isPaid()) {
+ if (!$payment->hasRefunds() && !$payment->hasChargebacks()) {
+ // The payment is paid and isn't refunded or charged back.
+ // Update the balance, if it wasn't already
+ if ($db_payment->status != 'paid') {
+ $db_payment->wallet->credit($db_payment->amount);
+ }
+ } elseif ($payment->hasRefunds()) {
+ // The payment has been (partially) refunded.
+ // The status of the payment is still "paid"
+ // TODO: Update balance
+ } elseif ($payment->hasChargebacks()) {
+ // The payment has been (partially) charged back.
+ // The status of the payment is still "paid"
+ // TODO: Update balance
+ }
+ }
+
+ // This is a sanity check, just in case the payment provider api
+ // sent us open -> paid -> open -> paid. So, we lock the payment after it's paid.
+ if ($db_payment->status != 'paid') {
+ $db_payment->status = $payment->status;
+ $db_payment->save();
+ }
+
+ return response('Success', 200);
+ }
+
+ /**
+ * Charge a wallet with a "recurring" payment.
+ *
+ * @param \App\Wallet $wallet The wallet to charge
+ * @param int $amount The amount of money in cents
+ *
+ * @return bool
+ */
+ public static function directCharge(Wallet $wallet, $amount): bool
+ {
+ $customer_id = $wallet->owner->getSetting('mollie_id');
+
+ if (empty($customer_id)) {
+ return false;
+ }
+
+ // Check if there's at least one valid mandate
+ $mandates = mollie()->mandates()->listFor($customer_id)->filter(function ($mandate) {
+ return $mandate->isValid();
+ });
+
+ if (empty($mandates)) {
+ return false;
+ }
+
+ $payment_request = [
+ 'amount' => [
+ 'currency' => 'CHF',
+ // a number with two decimals is required
+ 'value' => sprintf('%.2f', $amount / 100),
+ ],
+ 'customerId' => $customer_id,
+ 'sequenceType' => 'recurring',
+ 'description' => 'Kolab Now Recurring Payment',
+ 'webhookUrl' => self::serviceUrl('/api/webhooks/payment/mollie'),
+ ];
+
+ // Create the payment in Mollie
+ $payment = mollie()->payments()->create($payment_request);
+
+ // Store the payment reference in database
+ self::storePayment($payment, $wallet->id, $amount);
+
+ return true;
+ }
+
+ /**
+ * Create self URL
+ *
+ * @param string $route Route/Path
+ *
+ * @return string Full URL
+ */
+ protected static function serviceUrl(string $route): string
+ {
+ $url = \url($route);
+
+ // When testing the host might be e.g. 127.0.0.1:8000.
+ // This will not be accepted by Mollie. Let's use our fqdn instead.
+ // This does not have to be working URL, we do not require Mollie
+ // to come back (yet).
+ if (preg_match('|^https?://[0-9][^/]+|', $url, $matches)) {
+ $url = str_replace($matches[0], \config('app.public_url'), $url);
+ }
+
+ return $url;
+ }
+
+ /**
+ * Create a payment record in DB
+ *
+ * @param object $payment Mollie payment
+ * @param string $wallet_id Wallet ID
+ * @param int $amount Amount of money in cents
+ */
+ protected static function storePayment($payment, $wallet_id, $amount): void
+ {
+ $db_payment = new Payment();
+ $db_payment->id = $payment->id;
+ $db_payment->description = $payment->description;
+ $db_payment->status = $payment->status;
+ $db_payment->amount = $amount;
+ $db_payment->wallet_id = $wallet_id;
+ $db_payment->save();
+ }
+}
diff --git a/src/app/Http/Middleware/TrustProxies.php b/src/app/Http/Middleware/TrustProxies.php
--- a/src/app/Http/Middleware/TrustProxies.php
+++ b/src/app/Http/Middleware/TrustProxies.php
@@ -12,7 +12,7 @@
*
* @var array|string
*/
- protected $proxies;
+ protected $proxies = [ '127.0.0.1' ];
/**
* The headers that should be used to detect proxies.
diff --git a/src/app/Jobs/WalletPayment.php b/src/app/Jobs/WalletPayment.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/WalletPayment.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Wallet;
+use App\Http\Controllers\API\PaymentsController;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+
+class WalletPayment implements ShouldQueue
+{
+ use Dispatchable;
+ use InteractsWithQueue;
+ use Queueable;
+ use SerializesModels;
+
+ protected $wallet;
+
+ public $tries = 5;
+
+ /** @var bool Delete the job if its models no longer exist. */
+ public $deleteWhenMissingModels = true;
+
+
+ /**
+ * Create a new job instance.
+ *
+ * @param \App\Wallet $wallet The wallet to charge.
+ *
+ * @return void
+ */
+ public function __construct(Wallet $wallet)
+ {
+ $this->wallet = $wallet;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ if (!$this->wallet->balance < 0) {
+ PaymentsController::directCharge($this->wallet, $this->wallet->balance * -1);
+ }
+ }
+}
diff --git a/src/app/Observers/WalletObserver.php b/src/app/Observers/WalletObserver.php
--- a/src/app/Observers/WalletObserver.php
+++ b/src/app/Observers/WalletObserver.php
@@ -60,5 +60,11 @@
if ($wallet->entitlements()->count() > 0) {
return false;
}
+/*
+ // can't remove a wallet that has payments attached.
+ if ($wallet->payments()->count() > 0) {
+ return false;
+ }
+*/
}
}
diff --git a/src/app/Payment.php b/src/app/Payment.php
new file mode 100644
--- /dev/null
+++ b/src/app/Payment.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * A payment operation on a wallet.
+ *
+ * @property int $amount Amount of money in cents
+ * @property string $description Payment description
+ * @property string $id Mollie's Payment ID
+ * @property int $wallet_id The ID of the wallet
+ */
+class Payment extends Model
+{
+ public $incrementing = false;
+ protected $keyType = 'string';
+
+ protected $casts = [
+ 'amount' => 'integer'
+ ];
+
+ /**
+ * The wallet to which this payment belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function wallet()
+ {
+ return $this->belongsTo(
+ '\App\Wallet',
+ 'wallet_id', /* local */
+ 'id' /* remote */
+ );
+ }
+}
diff --git a/src/app/Wallet.php b/src/app/Wallet.php
--- a/src/app/Wallet.php
+++ b/src/app/Wallet.php
@@ -93,7 +93,6 @@
return $charges;
}
-
/**
* Calculate the expected charges to this wallet.
*
@@ -121,11 +120,11 @@
/**
* Add an amount of pecunia to this wallet's balance.
*
- * @param float $amount The amount of pecunia to add.
+ * @param int $amount The amount of pecunia to add (in cents).
*
- * @return Wallet
+ * @return Wallet Self
*/
- public function credit(float $amount)
+ public function credit(int $amount): Wallet
{
$this->balance += $amount;
@@ -137,11 +136,11 @@
/**
* Deduct an amount of pecunia from this wallet's balance.
*
- * @param float $amount The amount of pecunia to deduct.
+ * @param int $amount The amount of pecunia to deduct (in cents).
*
- * @return Wallet
+ * @return Wallet Self
*/
- public function debit(float $amount)
+ public function debit(int $amount): Wallet
{
$this->balance -= $amount;
@@ -184,4 +183,14 @@
{
return $this->belongsTo('App\User', 'user_id', 'id');
}
+
+ /**
+ * Payments on this wallet.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function payments()
+ {
+ return $this->hasMany('App\Payment');
+ }
}
diff --git a/src/composer.json b/src/composer.json
--- a/src/composer.json
+++ b/src/composer.json
@@ -22,6 +22,7 @@
"kolab/net_ldap3": "dev-master",
"laravel/framework": "6.*",
"laravel/tinker": "^1.0",
+ "mollie/laravel-mollie": "^2.9",
"morrislaptop/laravel-queue-clear": "^1.2",
"silviolleite/laravelpwa": "^1.0",
"spatie/laravel-translatable": "^4.2",
diff --git a/src/config/app.php b/src/config/app.php
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -53,6 +53,8 @@
'url' => env('APP_URL', 'http://localhost'),
+ 'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')),
+
'asset_url' => env('ASSET_URL', null),
/*
diff --git a/src/database/migrations/2020_03_16_100000_create_payments.php b/src/database/migrations/2020_03_16_100000_create_payments.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2020_03_16_100000_create_payments.php
@@ -0,0 +1,40 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreatePayments extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'payments',
+ function (Blueprint $table) {
+ $table->string('id', 16)->primary();
+ $table->string('wallet_id', 36);
+ $table->string('status', 16);
+ $table->integer('amount');
+ $table->text('description');
+ $table->timestamps();
+
+ $table->foreign('wallet_id')->references('id')->on('wallets')->onDelete('cascade');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('payments');
+ }
+}
diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss
--- a/src/resources/sass/app.scss
+++ b/src/resources/sass/app.scss
@@ -160,8 +160,8 @@
.badge {
position: absolute;
- top: .5rem;
- right: .5rem;
+ top: 0.5rem;
+ right: 0.5rem;
}
}
diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue
--- a/src/resources/vue/Wallet.vue
+++ b/src/resources/vue/Wallet.vue
@@ -7,6 +7,7 @@
<p>Current account balance is
<span :class="balance < 0 ? 'text-danger' : 'text-success'"><strong>{{ $root.price(balance) }}</strong></span>
</p>
+ <button type="button" class="btn btn-primary" @click="payment()">Add 10 bucks to my wallet</button>
</div>
</div>
</div>
@@ -28,6 +29,14 @@
})
},
methods: {
+ payment() {
+ axios.post('/api/v4/payments', {amount: 1000})
+ .then(response => {
+ if (response.data.redirectUrl) {
+ location.href = response.data.redirectUrl
+ }
+ })
+ }
}
}
</script>
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -49,5 +49,9 @@
Route::apiResource('skus', API\SkusController::class);
Route::apiResource('users', API\UsersController::class);
Route::apiResource('wallets', API\WalletsController::class);
+
+ Route::post('payments', 'API\PaymentsController@store');
}
);
+
+Route::post('webhooks/payment/mollie', 'API\PaymentsController@webhook');
diff --git a/src/tests/Browser/Components/Error.php b/src/tests/Browser/Components/Error.php
--- a/src/tests/Browser/Components/Error.php
+++ b/src/tests/Browser/Components/Error.php
@@ -13,7 +13,7 @@
400 => "Bad request",
401 => "Unauthorized",
403 => "Access denied",
- 404 => "Not Found",
+ 404 => "Not found",
405 => "Method not allowed",
500 => "Internal server error",
];
@@ -44,8 +44,10 @@
public function assert($browser)
{
$browser->waitFor($this->selector())
- ->assertSeeIn('@code', $this->code)
- ->assertSeeIn('@message', $this->message);
+ ->assertSeeIn('@code', $this->code);
+
+ $message = $browser->text('@message');
+ PHPUnit::assertSame(strtolower($message), strtolower($this->message));
}
/**
diff --git a/src/tests/Browser/Pages/Wallet.php b/src/tests/Browser/Pages/PaymentMollie.php
copy from src/tests/Browser/Pages/Wallet.php
copy to src/tests/Browser/Pages/PaymentMollie.php
--- a/src/tests/Browser/Pages/Wallet.php
+++ b/src/tests/Browser/Pages/PaymentMollie.php
@@ -4,7 +4,7 @@
use Laravel\Dusk\Page;
-class Wallet extends Page
+class PaymentMollie extends Page
{
/**
* Get the URL for the page.
@@ -13,7 +13,7 @@
*/
public function url(): string
{
- return '/wallet';
+ return '';
}
/**
@@ -25,9 +25,7 @@
*/
public function assert($browser)
{
- $browser->assertPathIs($this->url())
- ->waitUntilMissing('@app .app-loader')
- ->assertSeeIn('#wallet .card-title', 'Account balance');
+ $browser->waitFor('#container');
}
/**
@@ -38,7 +36,11 @@
public function elements(): array
{
return [
- '@app' => '#app',
+ '@form' => '#container',
+ '@title' => '#container .header__info',
+ '@amount' => '#container .header__amount',
+ '@methods' => '#payment-method-list',
+ '@status-table' => 'table.table--select-status',
];
}
}
diff --git a/src/tests/Browser/Pages/Wallet.php b/src/tests/Browser/Pages/Wallet.php
--- a/src/tests/Browser/Pages/Wallet.php
+++ b/src/tests/Browser/Pages/Wallet.php
@@ -39,6 +39,7 @@
{
return [
'@app' => '#app',
+ '@main' => '#wallet'
];
}
}
diff --git a/src/tests/Browser/PaymentTest.php b/src/tests/Browser/PaymentTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/PaymentTest.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Tests\Browser;
+
+use App\Wallet;
+use Tests\Browser;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\PaymentMollie;
+use Tests\Browser\Pages\Wallet as WalletPage;
+use Tests\TestCaseDusk;
+
+class PaymentTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('payment-test@kolabnow.com');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('payment-test@kolabnow.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test a payment process
+ *
+ * @group mollie
+ */
+ public function testPayment(): void
+ {
+ $user = $this->getTestUser('payment-test@kolabnow.com', [
+ 'password' => 'simple123',
+ ]);
+
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('payment-test@kolabnow.com', 'simple123', true)
+ ->on(new Dashboard())
+ ->click('@links .link-wallet')
+ ->on(new WalletPage())
+ ->click('@main button')
+ ->on(new PaymentMollie())
+ ->assertSeeIn('@title', 'Kolab Now Payment')
+ ->assertSeeIn('@amount', 'CHF 10.00');
+
+ // Looks like the Mollie testing mode is limited.
+ // We'll select credit card method and mark the payment as paid
+ // We can't do much more, we have to trust Mollie their page works ;)
+
+ // For some reason I don't get the method selection form, it
+ // immediately jumps to the next step. Let's detect that
+ if ($browser->element('@methods')) {
+ $browser->click('@methods button.grid-button-creditcard')
+ ->waitFor('button.form__button');
+ }
+
+ $browser->click('@status-table input[value="paid"]')
+ ->click('button.form__button');
+
+ // Now it should redirect back to wallet page and in background
+ // use the webhook to update payment status (and balance).
+
+ // Looks like in test-mode the webhook is executed before redirect
+ // so we can expect balance updated on the wallet page
+
+ $browser->waitForLocation('/wallet')
+ ->on(new WalletPage())
+ ->assertSeeIn('@main .card-text', 'Current account balance is 10,00 CHF');
+ });
+ }
+}
diff --git a/src/tests/Feature/Controller/PaymentsTest.php b/src/tests/Feature/Controller/PaymentsTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/PaymentsTest.php
@@ -0,0 +1,156 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Payment;
+use App\Wallet;
+use GuzzleHttp\Psr7\Response;
+use Tests\TestCase;
+
+class PaymentsTest extends TestCase
+{
+ use \Tests\MollieMocksTrait;
+
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $john = $this->getTestUser('john@kolab.org');
+ $wallet = $john->wallets()->first();
+ $john->setSetting('mollie_id', null);
+ Payment::where('wallet_id', $wallet->id)->delete();
+ Wallet::where('id', $wallet->id)->update(['balance' => 0]);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $wallet = $john->wallets()->first();
+ $john->setSetting('mollie_id', null);
+ Payment::where('wallet_id', $wallet->id)->delete();
+ Wallet::where('id', $wallet->id)->update(['balance' => 0]);
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test creating a payment and receiving a status via webhook)
+ *
+ * @group mollie
+ */
+ public function testStoreAndWebhook(): void
+ {
+ // 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']);
+ $this->assertSame('The amount must be at least 1.', $json['errors']['amount'][0]);
+
+ $post = ['amount' => 1234];
+ $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('Kolab Now 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",
+/*
+ "createdAt" => "2018-03-20T13:13:37+00:00",
+ "amount" => {
+ "value" => "10.00",
+ "currency" => "EUR"
+ },
+ "description" => "Order #12345",
+ "method" => null,
+ "metadata" => {
+ "order_id" => "12345"
+ },
+ "isCancelable" => false,
+ "locale" => "nl_NL",
+ "restrictPaymentMethodsToCountry" => "NL",
+ "expiresAt" => "2018-03-20T13:28:37+00:00",
+ "details" => null,
+ "profileId" => "pfl_QkEhN94Ba",
+ "sequenceType" => "oneoff",
+ "redirectUrl" => "https://webshop.example.org/order/12345/",
+ "webhookUrl" => "https://webshop.example.org/payments/webhook/",
+*/
+ ];
+
+ // 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('paid', $payment->fresh()->status);
+ $this->assertEquals(1234, $wallet->fresh()->balance);
+
+ // 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('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('paid', $payment->fresh()->status);
+ $this->assertEquals(1234, $wallet->fresh()->balance);
+ }
+
+ public function testDirectCharge(): void
+ {
+ $this->markTestIncomplete();
+ }
+}
diff --git a/src/tests/MollieMocksTrait.php b/src/tests/MollieMocksTrait.php
new file mode 100644
--- /dev/null
+++ b/src/tests/MollieMocksTrait.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Tests;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
+use Mollie\Api\MollieApiClient;
+
+trait MollieMocksTrait
+{
+ public $mollieRequestHistory = [];
+
+ /**
+ * Make Mollie's Guzzle instance use a mock handler.
+ *
+ * @see http://docs.guzzlephp.org/en/stable/testing.html
+ *
+ * @return \GuzzleHttp\Handler\MockHandler
+ */
+ public function mockMollie()
+ {
+ $handler = HandlerStack::create(
+ $mockHandler = new MockHandler()
+ );
+
+ $handler->push(
+ Middleware::history($this->mollieRequestHistory)
+ );
+
+ $guzzle = new Client(['handler' => $handler]);
+
+ $this->app->forgetInstance('mollie.api.client');
+ $this->app->forgetInstance('mollie.api');
+ $this->app->forgetInstance('mollie');
+
+ $this->app->singleton('mollie.api.client', function () use ($guzzle) {
+ return new MollieApiClient($guzzle);
+ });
+
+ return $mockHandler;
+ }
+
+ public function unmockMollie()
+ {
+ $this->app->forgetInstance('mollie.api.client');
+ $this->app->forgetInstance('mollie.api');
+ $this->app->forgetInstance('mollie');
+ }
+}

File Metadata

Mime Type
text/plain
Expires
Fri, Apr 3, 4:30 AM (15 h, 3 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18822574
Default Alt Text
D1030.1775190602.diff (28 KB)

Event Timeline