diff --git a/README.md b/README.md index 4cab4fe..667e3cb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,14 @@ - [Introduction](#Introduction) - [Installation](#Installation) +- [Database Migrations](#Database-Migrations) - [Configuration](#configuration) + - [Billable Model](#Billable-Model) + - [YouCanPay Keys](#YouCanPay-Keys) +- [Customers](#Customers) + - [Retrieving Customers](#Retrieving-Customers) + - [Generate Token](#Generate-Token) + - [Generate Payment URL](#Generate-Payment-URL) - [Usage](#Usage) - [Tokenization](#Create-a-payment) - [Get Token id](#Get-token-id) @@ -20,8 +27,10 @@ - [Metadata](#Metadata) - [Generate Payment form](#generate-payment-form) - [Handling YouCanPay Webhooks](#Handling-YouCanPay-Webhooks) - - [Verify webhook signature](#Verify-webhook-signature) - - [Validate webhook signature](#validate-webhook-signature) + - [Webhooks URl]() + - [Webhooks Events]() + - [Verify webhook signature manually](#Verify-webhook-signature) + - [Validate webhook signature manually](#validate-webhook-signature) - [Testing and test cards](#Testing-and-test-cards) ## Introduction @@ -36,6 +45,20 @@ You can install the package via composer: composer require devinweb/laravel-youcan-pay ``` +## Database Migrations + +LaravelYouCanPay package provides its own database to manage the user transactions in different steps, the migrations will create a new `transactions` table to hold all your user's transactions. + +```shell +php artisan migrate +``` + +If you need to overwrite the migrations that ship with LaravelYouCanPay, you can publish them using the vendor:publish Artisan command: + +```shell +php artisan vendor:publish --tag="youcanpay-migrations" +``` + ## Configuration To publish the config file you can use the command @@ -46,7 +69,63 @@ php artisan vendor:publish --tag="youcanpay-config" then you can find the config file in `config/youcanpay.php` -## YouCanPay Keys +### Billable Model + +If you want the package to manage the transactions based on the user model, add the `Billable` trait to your user model. +This trait provides various methods to perform transaction tasks, such as creating a transaction, getting `paid`, `failed` and `pending` transactions. + +```php +use Devinweb\LaravelYoucanPay\Traits\Billable; + +class User extends Authenticatable +{ + use Billable; +} +``` + +LaravelYoucanPay assumes your user model will be `App\Models\User`, if you use a different user model namespace you should specify it using the method `useCustomerModel` method. +This method should typically be called in the boot method of your `AppServiceProvider` class + +```php +use App\Models\Core\User; +use Devinweb\LaravelYoucanPay\LaravelYoucanPay;; + +/** + * Bootstrap any application services. + * + * @return void + */ +public function boot() +{ + LaravelYoucanPay::useCustomerModel(User::class); +} +``` + +If you need in each transaction the package uses the billing data for each user, make sure to include a `getCustomerInfo()` method in your user model, which returns an array that contains all the data we need. + +```php + +/** + * Get the customer info to send them when we generate the form token. + * + * @return array + */ +public function getCustomerInfo() +{ + return [ + 'name' => $this->name, + 'address' => '', + 'zip_code' => '', + 'city' => '', + 'state' => '', + 'country_code' => 'MA', + 'phone' => $this->phone, + 'email' => $this->email, + ]; +} +``` + +### YouCanPay Keys Next, you should configure your environment in your application's `.env` @@ -60,6 +139,58 @@ SUCCCESS_REDIRECT_URI= FAIL_REDIRECT_URI= ``` +## Customers + +### Retrieving Customers + +You can retrieve a customer by their YouCanPay ID using the `findBillable` method. This method will return an instance of the billable model: + +```php + +use Devinweb\LaravelYoucanPay\Facades\LaravelYoucanPay; + +$user = LaravelYoucanPay::findBillable($order_id); + +``` + +### Generate Token + +If you need to generate the token form the user model the cutomer info will be attached directly from `getCustomerInfo` method + +```php + +$data= [ + 'order_id' => '123', + 'amount' => 2000 // amount=20*100 +]; + +$token = $user->getPaymentToken($data, $request); +``` + +If you need to add the metadata you can use + +```php + +$data= [ + 'order_id' => '123', + 'amount' => 2000 // amount=20*100 +]; + +$metadata = [ + 'key' => 'value' +]; + +$token = $user->getPaymentToken($data, $request, $metadata); +``` + +If you need to get the payment url as well from the user model you can use `getPaymentURL` method with the same parameters below. + +```php +$payment_url = $user->getPaymentURL($data, $request, $metadata); +``` + +### Generate Payment URL + ## Usage Before starting using the package make sure to update your `config/youcanpay.php` with the correct values provided by YouCanPay. @@ -86,7 +217,7 @@ public function tokenization(Request $request) 'amount' => 200 ]; - $token= LaravelYoucanPay::createTokenization($data, $request)->getId(); + $token= LaravelYoucanPay::createTokenization($order_data, $request)->getId(); $public_key = config('youcanpay.public_key'); $isSandbox = config('youcanpay.sandboxMode'); $language = config('app.locale'); @@ -116,7 +247,7 @@ Then you can put that url in your html page #### Customer info -If you need to add the customer data during the tokenization you can use +If you need to add the customer data during the tokenization, Please keep these array keys(`name`, `address`, `zip_code`, `city`, `state`, `country_code`, `phone` and `email`). you can use ```php use Devinweb\LaravelYoucanPay\Facades\LaravelYoucanPay; @@ -158,7 +289,7 @@ $metadata = [ 'key' => 'value' ]; -$token= LaravelYoucanPay::seMetadata($metadata) +$token= LaravelYoucanPay::setMetadata($metadata) ->setCustomerInfo($customerInfo) ->createTokenization($data, $request)->getId(); ``` @@ -219,12 +350,105 @@ For more information please check this [link](https://github.com/NextmediaMa/you YouCan Pay uses webhooks to notify your application when an event happens in your account. Webhooks are useful for handling reactions to asynchronous events on your backend, such as successful payments, failed payments, successful refunds, and many other real time events. A webhook enables YouCan Pay to push real-time notifications to your application by delivering JSON payloads over HTTPS. -Before making any action related to the events received by YouCanPay, you can need to verify the signature to make sure the payload was received from YouCan pay services. -there's two method `verifyWebhookSignature` and `validateWebhookSignature` +> #### Webhooks and CSRF Protection +> +> YouCanPay webhooks need to reach your URI without any obstacle, so you need to disable CSRF protection for the webhook URI, to do that +> make sure to add your path to the exception array in your application's `App\Http\Middleware\VerifyCsrfToken` middleware. +> +> ```php +> protected $except = [ +> 'youcanpay/*', +> ] +> ``` + +#### Webhooks URL + +To ensure your application can handle YouCanPay webhooks, be sure to configure the webhook URL in the YouCanPay control panel. By default the package comes with a webhook build-in +using the URL `youcanpay/webhook` you can find it by listing all the routes in your app using `php artisan route:list`, +This webhook validates the signature related to the payload received, and dispatches an event. + +#### Webhooks Middleware + +If you need to attempt the webhook signature validation before processing any action, you can use the middleware `verify-youcanpay-webhook-signature`, that validates the signature related to the payload received from YouCanPay. + +```php + +use Illuminate\Http\Request; +use Illuminate\Routing\Controller; + +class WebHookController extends Controller +{ + /** + * Create a new WebhookController instance. + * + * @return void + */ + public function __construct() + { + $this->middleware('verify-youcanpay-webhook-signature'); + } + //... +} +``` + +#### Webhook Events + +LaravelYouCanPay handles the common YouCanPay webhook events, if you need to handle the webhook events that you need you can listen to the event that is dispatched by the package. + +- Devinweb\LaravelYoucanPay\Events\WebhookReceived + +You need to register a listener that can handle the event: + +```php +payload['event_name'] === 'transaction.paid') { + // Handle the incoming event... + } + } +} + +``` + +Once your listener has been defined, you may register it within your application's `EventServiceProvider` + +```php + [ + YouCanPayEventListener::class, + ], + ]; +} + +``` -#### Verify webhook signature +#### Verify webhook signature Manually -The webhook data looks like +The webhook data received from YouCanPay looks like ``` [ @@ -293,7 +517,7 @@ class YouCanPayWebhooksController extends Controller public function handle(Request $request) { $signature = $request->header('x-youcanpay-signature'); - $payload = $request->get('payload'); + $payload = json_decode($request->getContent(), true); if (LaravelYoucanPay::verifyWebhookSignature($signature, $payload)) { // you code here } @@ -301,7 +525,7 @@ class YouCanPayWebhooksController extends Controller } ``` -#### Validate webhook signature +#### Validate webhook signature Manually The validation has the same impact as the verification, but the validation throws an exception that you can inspect it in the log file. diff --git a/composer.json b/composer.json index 7d1cd02..8198e37 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,8 @@ "php": "^7.4|^8.0", "illuminate/support": "^7.0|^8.0", "laravel/framework": "8.*", - "youcanpay/payment-sdk": "*" + "youcanpay/payment-sdk": "*", + "spatie/laravel-enum": "^2.0|^3.0" }, "require-dev": { "orchestra/testbench": "^6.0", @@ -28,7 +29,8 @@ }, "autoload": { "psr-4": { - "Devinweb\\LaravelYoucanPay\\": "src" + "Devinweb\\LaravelYoucanPay\\": "src", + "Devinweb\\LaravelYoucanPay\\Database\\Factories\\": "database/factories/" } }, "autoload-dev": { diff --git a/config/youcanpay.php b/config/youcanpay.php index 4fc75d2..ed0849d 100644 --- a/config/youcanpay.php +++ b/config/youcanpay.php @@ -15,6 +15,6 @@ "success_redirect_uri" => env("SUCCCESS_REDIRECT_URI"), - "fail_redirect_uri" => env("FAIL_REDIRECT_URI"), + "fail_redirect_uri" => env("FAIL_REDIRECT_URI") ]; diff --git a/database/factories/TransactionFactory.php b/database/factories/TransactionFactory.php new file mode 100644 index 0000000..683ea9c --- /dev/null +++ b/database/factories/TransactionFactory.php @@ -0,0 +1,39 @@ +getForeignKey() => ($model)::factory(), + 'name' => 'default', + 'order_id' => Str::random(40), + 'youcanpay_id' => Str::random(40), + 'status' => YouCanPayStatus::PAID(), + 'price' => null, + 'payload'=> [] + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..08da549 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,31 @@ + $this->faker->name(), + 'email' => $this->faker->unique()->safeEmail(), + 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password + ]; + } +} diff --git a/database/migrations/2020_11_22_000001_create_transactions_table.php b/database/migrations/2020_11_22_000001_create_transactions_table.php new file mode 100644 index 0000000..0bea669 --- /dev/null +++ b/database/migrations/2020_11_22_000001_create_transactions_table.php @@ -0,0 +1,38 @@ +bigIncrements('id'); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('name'); + $table->string('order_id')->unique(); + $table->string('youcanpay_id')->unique(); + $table->string('status'); + $table->string('price')->nullable(); + $table->string('refund')->nullable(); + $table->json('payload')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('transactions'); + } +}; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 34ca525..a247081 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -26,5 +26,9 @@ + + + + \ No newline at end of file diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..63a5007 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,6 @@ +name('webhook'); diff --git a/src/Enums/YouCanPayStatus.php b/src/Enums/YouCanPayStatus.php new file mode 100644 index 0000000..0c3bdbb --- /dev/null +++ b/src/Enums/YouCanPayStatus.php @@ -0,0 +1,16 @@ +payload = $payload; + } +} diff --git a/src/Http/Controllers/WebHookController.php b/src/Http/Controllers/WebHookController.php new file mode 100644 index 0000000..43c94ae --- /dev/null +++ b/src/Http/Controllers/WebHookController.php @@ -0,0 +1,101 @@ +middleware('verify-youcanpay-webhook-signature'); + } + + /** + * Handle a YouCanPay webhook call. + * + * @param \Illuminate\Http\Request $request + * @return \Symfony\Component\HttpFoundation\Response + */ + public function handleWebhook(Request $request) + { + $payload = json_decode($request->getContent(), true); + + $method = 'handle'.Str::studly(str_replace('.', '_', $payload['event_name'])); + + WebhookReceived::dispatch($payload); + + if (method_exists($this, $method)) { + $this->{$method}($payload); + + return new Response('Webhook Handled', 200); + } + + return new Response; + } + + /** + * Handle transaction paid webhook event. + * + * @param array $payload + * @return void + */ + protected function handleTransactionPaid(array $payload) + { + $customer = Arr::get($payload, 'payload.customer'); + $transaction = Arr::get($payload, 'payload.transaction'); + $youcanpay_id = Arr::get($payload, 'id'); + $user_model = LaravelYoucanPay::$customerModel; + $user = (new $user_model)->whereEmail($customer['email'])->first(); + + Transaction::create([ + 'user_id' => $user? $user->id : null, + 'name' => 'default', + 'order_id' => $transaction['order_id'], + 'status' => YouCanPayStatus::PAID(), + 'youcanpay_id' => $youcanpay_id, + 'price' => $transaction['amount'], + 'payload' => $payload + ]); + } + + // /** + // * Handle transaction failed webhook event. + // * + // * @param array $payload + // * @return void + // */ + // protected function handleTransactionFailed(array $payload) + // { + // Log::info([ + // 'failed' => $payload + // ]); + // } + + // /** + // * Handle transaction refunded webhook event. + // * + // * @param array $payload + // * @return void + // */ + // protected function handleTransactionRefunded(array $payload) + // { + // Log::info([ + // 'refunded' => $payload + // ]); + // } +} diff --git a/src/Http/Middleware/VerifyWebhookSignature.php b/src/Http/Middleware/VerifyWebhookSignature.php new file mode 100644 index 0000000..c4b60d8 --- /dev/null +++ b/src/Http/Middleware/VerifyWebhookSignature.php @@ -0,0 +1,28 @@ +getContent(), true); + $signature = $request->header('x-youcanpay-signature'); + LaravelYoucanPay::validateWebhookSignature($signature, $payload); + + return $next($request); + } +} diff --git a/src/LaravelYoucanPay.php b/src/LaravelYoucanPay.php index 6874cff..f9ebde5 100644 --- a/src/LaravelYoucanPay.php +++ b/src/LaravelYoucanPay.php @@ -3,6 +3,7 @@ namespace Devinweb\LaravelYoucanPay; use Devinweb\LaravelYoucanPay\Actions\CreateToken; +use Devinweb\LaravelYoucanPay\Models\Transaction; use Illuminate\Http\Request; use Illuminate\Support\Arr; use InvalidArgumentException; @@ -65,6 +66,13 @@ class LaravelYoucanPay */ private $customer_info; + /** + * The default customer model class name. + * + * @var string + */ + public static $customerModel = 'App\\Models\\User'; + /** * Create a new LaravelYouCanPay instance. * @@ -88,7 +96,7 @@ public function __construct() * * @param array $paramters * @param \Illuminate\Http\Request $request - * @return void + * @return $this */ public function createTokenization(array $attributes, Request $request) { @@ -114,6 +122,27 @@ public function createTokenization(array $attributes, Request $request) return $this; } + /** + * Set the customer model class name. + * + * @param string $customerModel + * @return void + */ + public static function useCustomerModel($customerModel) + { + static::$customerModel = $customerModel; + } + + /** + * Get the customer instance by its YouCanPay ID. + * + * @param string|null $orderId + * @return \Illuminate\Database\Eloquent\Model|null + */ + public function findBillable($orderId) + { + return $orderId ? Transaction::where('order_id', $orderId)->first()->user : null; + } /** * Set the customer data diff --git a/src/Models/Transaction.php b/src/Models/Transaction.php new file mode 100644 index 0000000..49b7abc --- /dev/null +++ b/src/Models/Transaction.php @@ -0,0 +1,150 @@ +|bool + */ + protected $guarded = []; + + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'payload' => 'array', + ]; + + /** + * The attributes that should be mutated to dates. + * + * @var array + */ + protected $dates = [ + 'created_at', + 'updated_at', + ]; + + /** + * The status should be cast to the native types. + * + * @var array + */ + protected $enums = [ + 'status' => YouCanPayStatus::class, + ]; + + /** + * Get the user that owns the subscription. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function user() + { + return $this->owner(); + } + + /** + * Get the model related to the transactions. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function owner() + { + $model = LaravelYoucanPay::$customerModel; + + return $this->belongsTo($model, (new $model)->getForeignKey()); + } + + /** + * Determine if the transaction is pending. + * + * @return bool + */ + public function isPending() + { + return $this->status == YouCanPayStatus::pending(); + } + + /** + * Determine if the transaction is paid. + * + * @return bool + */ + public function isPaid() + { + return $this->status == YouCanPayStatus::paid(); + } + + /** + * Determine if the transaction is paid. + * + * @return bool + */ + public function isFailed() + { + return $this->status == YouCanPayStatus::failed(); + } + + /** + * Filter query by paid. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return void + */ + public function scopePaid($query) + { + $query->where('status', YouCanPayStatus::PAID()); + } + + /** + * Filter query by failed. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return void + */ + public function scopeFailed($query) + { + $query->where('status', YouCanPayStatus::FAILED()); + } + + /** + * Filter query by pending. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return void + */ + public function scopePending($query) + { + $query->where('status', YouCanPayStatus::PENDING()); + } + + + /** + * Create a new factory instance for the model. + * + * @return \Illuminate\Database\Eloquent\Factories\Factory + */ + protected static function newFactory() + { + // return TransactionFactory::new(); + + return TransactionFactory::new(); + } +} diff --git a/src/Providers/LaravelYoucanPayServiceProvider.php b/src/Providers/LaravelYoucanPayServiceProvider.php index 0758e9b..71f6679 100644 --- a/src/Providers/LaravelYoucanPayServiceProvider.php +++ b/src/Providers/LaravelYoucanPayServiceProvider.php @@ -2,49 +2,59 @@ namespace Devinweb\LaravelYoucanPay\Providers; +use Devinweb\LaravelYoucanPay\Http\Middleware\VerifyWebhookSignature; use Devinweb\LaravelYoucanPay\LaravelYoucanPay; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Facades\Route; +use Illuminate\Routing\Router; class LaravelYoucanPayServiceProvider extends ServiceProvider { /** * Bootstrap the application services. + * @return void */ public function boot() { - /* - * Optional methods to load your package assets - */ - // $this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'laravel-youcan-pay'); - // $this->loadViewsFrom(__DIR__.'/../resources/views', 'laravel-youcan-pay'); - // $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); - // $this->loadRoutesFrom(__DIR__.'/routes.php'); + $this->registerMiddleware(); + $this->registerRoutes(); + $this->registerMigrations(); + $this->registerPublishing(); + } + + /** + * Register the package migrations. + * + * @return void + */ + protected function registerMigrations() + { + if ($this->app->runningInConsole()) { + $this->loadMigrationsFrom(__DIR__.'/../../database/migrations'); + } + } + + /** + * Register the package's publishable resources. + * + * @return void + */ + protected function registerPublishing() + { if ($this->app->runningInConsole()) { $this->publishes([ - __DIR__.'/../../config/youcanpay.php' => config_path('youcanpay.php'), - ], 'youcanpay-config'); - - // Publishing the views. - /*$this->publishes([ - __DIR__.'/../resources/views' => resource_path('views/vendor/laravel-youcan-pay'), - ], 'views');*/ - - // Publishing assets. - /*$this->publishes([ - __DIR__.'/../resources/assets' => public_path('vendor/laravel-youcan-pay'), - ], 'assets');*/ - - // Publishing the translation files. - /*$this->publishes([ - __DIR__.'/../resources/lang' => resource_path('lang/vendor/laravel-youcan-pay'), - ], 'lang');*/ - - // Registering package commands. - // $this->commands([]); + __DIR__.'/../../config/youcanpay.php' => config_path('youcanpay.php'), + ], 'youcanpay-config'); + + + $this->publishes([ + __DIR__.'/../../database/migrations' => $this->app->databasePath('migrations'), + ], 'youcanpay-migrations'); } } + /** * Register the application services. */ @@ -58,4 +68,32 @@ public function register() return new LaravelYoucanPay; }); } + + + /** + * Register the package routes. + * + * @return void + */ + protected function registerRoutes() + { + Route::group([ + 'prefix' => 'youcanpay', + 'namespace' => 'Devinweb\LaravelYoucanPay\Http\Controllers', + 'as' => 'youcanpay.', + ], function () { + $this->loadRoutesFrom(__DIR__.'/../../routes/web.php'); + }); + } + + /** + * Undocumented function + * + * @return void + */ + protected function registerMiddleware() + { + $router = $this->app->make(Router::class); + $router->aliasMiddleware('verify-youcanpay-webhook-signature', VerifyWebhookSignature::class); + } } diff --git a/src/Traits/Billable.php b/src/Traits/Billable.php new file mode 100644 index 0000000..874c156 --- /dev/null +++ b/src/Traits/Billable.php @@ -0,0 +1,88 @@ +hasMany(Transaction::class, $this->getForeignKey()); + } + + /** + * Get Payment token + * + * @param array $data + * @param Request $request + * @param array $metadata + * + * @throws \InvalidArgumentException + * @return string + */ + public function getPaymentToken(array $data, Request $request, array $metadata=[]) + { + $this->validateCustomerInfoFields(); + return $this->getInstance($data, $request, $metadata)->getId(); + } + + + /** + * Get Payment URL + * + * @param array $data + * @param Request $request + * @param array $metadata + * + * @throws \InvalidArgumentException + * @return string + */ + public function getPaymentURL(array $data, Request $request, array $metadata=[]) + { + $this->validateCustomerInfoFields(); + return $this->getInstance($data, $request, $metadata)->getPaymentURL(); + } + + /** + * Validate the cutomer info fields + * + * @throws \InvalidArgumentException + * @return void + */ + private function validateCustomerInfoFields() + { + if (!method_exists($this, 'getCustomerInfo')) { + throw new InvalidArgumentException("Please make sure to add getCustomerInfo that return an array"); + } + + $fields = ['name', 'address', 'zip_code', 'city', 'state', 'country_code', 'phone', 'email']; + + foreach ($fields as $field) { + if (! array_key_exists($field, $this->getCustomerInfo())) { + throw new InvalidArgumentException("Please make sure to add {$field} key to the array that returned by getCustomerInfo"); + } + } + } + + /** + * Get an instance of LaravelYoucanPay + * + * @param array $data + * @param Request $request + * @param array $metadata + * @return \Devinweb\LaravelYoucanPay\LaravelYoucanPay + */ + private function getInstance(array $data, Request $request, array $metadata=[]) + { + return LaravelYoucanPay::setCustomerInfo($this->getCustomerInfo())->setMetadata($metadata)->createTokenization($data, $request); + } +} diff --git a/tests/Fixtures/User.php b/tests/Fixtures/User.php new file mode 100644 index 0000000..32bcaa5 --- /dev/null +++ b/tests/Fixtures/User.php @@ -0,0 +1,46 @@ + $this->name, + 'address' => 'Wilaya center, Avenue Ali Yaeta, étage 3, n 31', + 'zip_code' => '93000', + 'city' => 'Tetouan', + 'state' => 'Tanger-Tétouan-Al Hoceïma', + 'country_code' => 'MA', + 'phone' => '0620000202', + 'email' => $this->email, + ]; + } + + + /** + * Create a new factory instance for the model. + * + * @return \Illuminate\Database\Eloquent\Factories\Factory + */ + protected static function newFactory() + { + return UserFactory::new(); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index ff1f98e..890663e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,8 +2,9 @@ namespace Devinweb\LaravelYoucanPay\Tests; -// use Devinweb\LaravelHyperpay\Tests\Fixtures\User; +use Devinweb\LaravelYoucanPay\LaravelYoucanPay; use Devinweb\LaravelYoucanPay\Providers\LaravelYoucanPayServiceProvider; +use Devinweb\LaravelYoucanPay\Tests\Fixtures\User; use Orchestra\Testbench\TestCase as OrchestraTestCase; abstract class TestCase extends OrchestraTestCase @@ -16,8 +17,8 @@ public function setUp(): void protected function defineDatabaseMigrations() { - // $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); - // $this->loadLaravelMigrations(); + $this->loadMigrationsFrom(__DIR__.'/../../database/migrations'); + $this->loadLaravelMigrations(); } protected function getPackageProviders($app) @@ -28,18 +29,20 @@ protected function getPackageProviders($app) protected function getEnvironmentSetUp($app) { // // import the CreatePostsTable class from the migration - // include_once __DIR__ . '/../database/migrations/create_transactions_table.php.stub'; + // include_once __DIR__ . '/../database/migrations/2020_11_22_000001_create_transactions_table.php'; - // // run the up() method of that migration class + // // // run the up() method of that migration class // (new \CreateTransactionsTable)->up(); + + LaravelYoucanPay::useCustomerModel(User::class); } - // protected function createCustomer($description = 'imad', array $options = []): User - // { - // return User::create(array_merge([ - // 'email' => "{$description}@hyperpay-laravel.com", - // 'name' => 'Darbaoui imad', - // 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', - // ], $options)); - // } + protected function createCustomer($email = 'imad', array $options = []): User + { + return User::create(array_merge([ + 'email' => "{$email}@devinweb.com", + 'name' => 'Darbaoui imad', + 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', + ], $options)); + } } diff --git a/tests/UserTest.php b/tests/UserTest.php new file mode 100644 index 0000000..ca388dd --- /dev/null +++ b/tests/UserTest.php @@ -0,0 +1,277 @@ +user = $this->createCustomer(); + } + + /** + * @test + * @return void + */ + public function test_user_can_generate_a_token() + { + LaravelYoucanPay::useCustomerModel(FixturesUser::class); + $token_id = 'token_id'; + + $required_data = [ + 'order_id' => '123', + 'amount' => '200' + ]; + + $request = $this->instance( + Request::class, + Mockery::mock(Request::class, static function (MockInterface $mock): void { + $mock->shouldReceive('ip')->once()->andReturn("123.123.123.123"); + }) + ); + + $this->instance( + CreateToken::class, + Mockery::mock(CreateToken::class, function (MockInterface $mock) use ($token_id) { + $mock->shouldReceive('__invoke')->andReturn((new FakeToken($token_id))); + }) + ); + + $token = $this->user->getPaymentToken($required_data, $request); + + $this->assertEquals($token_id, $token); + } + + /** + * @test + * @return void + */ + public function test_user_can_generate_a_payment_url() + { + LaravelYoucanPay::useCustomerModel(FixturesUser::class); + + $token_id = 'token_id'; + + $required_data = [ + 'order_id' => '123', + 'amount' => '200' + ]; + + $request = $this->instance( + Request::class, + Mockery::mock(Request::class, static function (MockInterface $mock): void { + $mock->shouldReceive('ip')->once()->andReturn("123.123.123.123"); + }) + ); + + $this->instance( + CreateToken::class, + Mockery::mock(CreateToken::class, function (MockInterface $mock) use ($token_id) { + $mock->shouldReceive('__invoke')->andReturn((new FakeToken($token_id))); + }) + ); + + $payment_url = "https://youcanpay.com/sandbox/payment-form/token_id?lang=en"; + $url = $this->user->getPaymentURL($required_data, $request); + $this->assertEquals($url, $payment_url); + } + + /** + * @test + * @return void + */ + public function test_an_exception_should_fired_if_getCustomerInfo_doesnt_added_to_the_user_model() + { + $user = User::create([ + 'email' => "imad-youcanpay@devinweb.com", + 'name' => 'Darbaoui imad', + 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', + ]); + + $token_id = 'token_id'; + + $required_data = [ + 'order_id' => '123', + 'amount' => '200' + ]; + + $request = new Request([], [], [], [], [], [], ''); + + $this->instance( + CreateToken::class, + Mockery::mock(CreateToken::class, function (MockInterface $mock) use ($token_id) { + $mock->shouldReceive('__invoke')->andReturn((new FakeToken($token_id))); + }) + ); + + try { + $user->getPaymentToken($required_data, $request); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('Please make sure to add getCustomerInfo that return an array', $e->getMessage()); + } + } + + /** + * @test + * @return void + */ + public function test_an_exception_should_fired_if_getCustomerInfo_exist_and_a_key_doesnt_exists_in_the_returned_array() + { + $user = UserWithMethodGetCustomerInfo::create([ + 'email' => "imad-youcanpay@devinweb.com", + 'name' => 'Darbaoui imad', + 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', + ]); + + $token_id = 'token_id'; + + $required_data = [ + 'order_id' => '123', + 'amount' => '200' + ]; + + $request = new Request([], [], [], [], [], [], ''); + + $this->instance( + CreateToken::class, + Mockery::mock(CreateToken::class, function (MockInterface $mock) use ($token_id) { + $mock->shouldReceive('__invoke')->andReturn((new FakeToken($token_id))); + }) + ); + + try { + $user->getPaymentToken($required_data, $request); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('Please make sure to add address key to the array that returned by getCustomerInfo', $e->getMessage()); + } + } + + /** + * @test + * @return void + */ + public function test_transactions() + { + \Devinweb\LaravelYoucanPay\Tests\Fixtures\User::factory() + ->has(Transaction::factory()->count(3)) + ->create(); + + $this->assertDatabaseCount('transactions', 3); + } + + /** + * @test + * @return void + */ + public function test_transaction_owner() + { + $user = \Devinweb\LaravelYoucanPay\Tests\Fixtures\User::factory() + ->has(Transaction::factory()->count(1)) + ->create(); + + $this->assertDatabaseCount('transactions', 1); + $owner = Transaction::first()->owner; + $user_owner = Transaction::first()->user; + + $this->assertEquals($user->email, $owner->email); + $this->assertEquals($user->email, $user_owner->email); + } + + /** + * @test + * @return void + */ + public function test_user_transaction_status() + { + $user = \Devinweb\LaravelYoucanPay\Tests\Fixtures\User::factory() + ->has(Transaction::factory()->count(1)) + ->create(); + + $this->assertDatabaseCount('transactions', 1); + + $transaction = $user->transactions()->first(); + + $this->assertTrue($transaction->isPaid()); + + $paid_count = $user->transactions()->paid()->count(); + + $pending_count = $user->transactions()->pending()->count(); + + $failed_count = $user->transactions()->failed()->count(); + + $this->assertEquals($paid_count, 1); + + $this->assertEquals($pending_count, 0); + + $this->assertEquals($failed_count, 0); + + $transaction = tap($transaction)->update(['status' => YouCanPayStatus::pending()]); + + $this->assertTrue($transaction->isPending()); + + $transaction = tap($transaction)->update(['status' => YouCanPayStatus::failed()]); + + $this->assertTrue($transaction->isFailed()); + } + + /** + * @test + * + * @return void + */ + public function test_find_billable_from_order_id() + { + $user = \Devinweb\LaravelYoucanPay\Tests\Fixtures\User::factory() + ->has(Transaction::factory()->count(1)) + ->create(); + + $transaction = Transaction::first(); + + $billable = LaravelYoucanPay::findBillable($transaction->order_id); + + $this->assertEquals($user->email, $billable->email); + } +} + + +class User extends Model +{ + use Billable; + protected $guarded = []; +} + +class UserWithMethodGetCustomerInfo extends User +{ + protected $table = 'users'; + + public function getCustomerInfo() + { + return [ + 'name' => $this->name, + 'zip_code' => '93000', + 'city' => 'Tetouan', + 'state' => 'Tanger-Tétouan-Al Hoceïma', + 'country_code' => 'MA', + 'phone' => '0620000202', + 'email' => $this->email, + ]; + } +} diff --git a/tests/WebHookEventsTest.php b/tests/WebHookEventsTest.php new file mode 100644 index 0000000..3a1dd8c --- /dev/null +++ b/tests/WebHookEventsTest.php @@ -0,0 +1,104 @@ + $youcanpay_id = 'a433f4de-b1f8-4e6a-a462-11ab2a92dba7', + 'event_name'=> 'transaction.paid', + 'payload' => [ + 'customer' => [ + 'email' => 'imad@devinweb.com' + ], + 'transaction' => [ + 'order_id'=> $order_id='123', + 'amount' => '2000' + ] + ] + ]; + + $signature = hash_hmac( + 'sha256', + json_encode($payload), + config('youcanpay.private_key'), + false + ); + + $response = $this->withHeaders([ + 'x-youcanpay-signature' => $signature, + ])->postJson(route('youcanpay.webhook'), $payload); + + Event::assertDispatched(WebhookReceived::class); + + $this->assertDatabaseHas('transactions', [ + 'order_id' => $order_id, + 'youcanpay_id' => $youcanpay_id, + ]); + + $response->assertOk(); + } + + /** + * @test + * @return void + */ + public function test_webhook_uri_with_event_name_not_found() + { + Event::fake(); + + $payload = [ + 'id' => $youcanpay_id = 'a433f4de-b1f8-4e6a-a462-11ab2a92dba7', + 'event_name'=> 'fake_event_name', + 'payload' => [ + 'customer' => [ + 'email' => 'imad@devinweb.com' + ], + 'transaction' => [ + 'order_id'=> $order_id='123', + 'amount' => '2000' + ] + ] + ]; + + $signature = hash_hmac( + 'sha256', + json_encode($payload), + config('youcanpay.private_key'), + false + ); + + $response = $this->withHeaders([ + 'x-youcanpay-signature' => $signature, + ])->postJson(route('youcanpay.webhook'), $payload); + + Event::assertDispatched(WebhookReceived::class); + + $response->assertOk(); + } +}