├── tests ├── resources │ └── views │ │ └── oauth │ │ ├── error.blade.php │ │ └── success.blade.php ├── lib │ ├── TestUser.php │ ├── TestAccount.php │ ├── Integration │ │ ├── Webhooks │ │ │ ├── WebhookTest.php │ │ │ ├── ProcessTest.php │ │ │ └── ListenersTest.php │ │ ├── EloquentTest.php │ │ ├── Connect │ │ │ └── DeauthorizeTest.php │ │ └── TestCase.php │ ├── TestWebhookJob.php │ └── Unit │ │ └── Connect │ │ └── AuthorizeUrlTest.php ├── database │ ├── migrations │ │ ├── 2019_07_16_000000_create_test_tables.php │ │ └── 2014_10_12_000000_create_users_table.php │ └── factories │ │ └── TestFactory.php └── stubs │ └── webhook.json ├── .gitignore ├── resources └── brand │ ├── badge │ ├── big │ │ ├── powered_by_stripe.pdf │ │ ├── powered_by_stripe.png │ │ ├── powered_by_stripe@2x.png │ │ └── powered_by_stripe@3x.png │ ├── outline-dark │ │ ├── powered_by_stripe.pdf │ │ ├── powered_by_stripe.png │ │ ├── powered_by_stripe@2x.png │ │ └── powered_by_stripe@3x.png │ ├── solid-dark │ │ ├── powered_by_stripe.pdf │ │ ├── powered_by_stripe.png │ │ ├── powered_by_stripe@2x.png │ │ └── powered_by_stripe@3x.png │ ├── solid-light │ │ ├── powered_by_stripe.pdf │ │ ├── powered_by_stripe.png │ │ ├── powered_by_stripe@2x.png │ │ └── powered_by_stripe@3x.png │ └── outline-light │ │ ├── powered_by_stripe.pdf │ │ ├── powered_by_stripe.png │ │ ├── powered_by_stripe@2x.png │ │ └── powered_by_stripe@3x.png │ └── connect-button │ ├── blue │ ├── blue-on-dark.png │ ├── blue-on-light.png │ ├── blue-on-dark@2x.png │ ├── blue-on-dark@3x.png │ ├── blue-on-light@2x.png │ └── blue-on-light@3x.png │ └── gray │ ├── light-on-dark.png │ ├── light-on-dark@2x.png │ ├── light-on-dark@3x.png │ ├── light-on-light.png │ ├── light-on-light@2x.png │ └── light-on-light@3x.png ├── .editorconfig ├── docs ├── console.md ├── testing.md └── installation.md ├── src ├── Exceptions │ ├── UnexpectedValueException.php │ └── AccountNotConnectedException.php ├── Repositories │ ├── EventRepository.php │ ├── Concerns │ │ ├── HasMetadata.php │ │ ├── Update.php │ │ ├── Retrieve.php │ │ └── All.php │ ├── BalanceRepository.php │ ├── PaymentIntentRepository.php │ ├── ChargeRepository.php │ ├── AccountRepository.php │ ├── RefundRepository.php │ └── AbstractRepository.php ├── Contracts │ ├── Connect │ │ ├── StateProviderInterface.php │ │ ├── AccountOwnerInterface.php │ │ ├── AccountInterface.php │ │ └── AdapterInterface.php │ └── Webhooks │ │ └── ProcessorInterface.php ├── Testing │ ├── StripeFake.php │ ├── Concerns │ │ └── MakesStripeAssertions.php │ └── ClientFake.php ├── Events │ ├── AccountDeauthorized.php │ ├── ClientWillSend.php │ ├── FetchedUserCredentials.php │ ├── ClientReceivedResult.php │ ├── SignatureVerificationFailed.php │ ├── OAuthSuccess.php │ ├── AbstractOAuthEvent.php │ └── OAuthError.php ├── Listeners │ ├── DispatchAuthorizeJob.php │ ├── LogClientRequests.php │ ├── RemoveAccountOnDeauthorize.php │ ├── LogClientResults.php │ └── DispatchWebhookJob.php ├── Connect │ ├── SessionState.php │ ├── OwnsStripeAccounts.php │ ├── Connector.php │ ├── Authorizer.php │ ├── ConnectedAccount.php │ └── Adapter.php ├── Webhooks │ ├── Verifier.php │ ├── ConnectWebhook.php │ └── Webhook.php ├── Client.php ├── Http │ ├── Controllers │ │ ├── WebhookController.php │ │ └── OAuthController.php │ ├── Middleware │ │ └── VerifySignature.php │ └── Requests │ │ └── AuthorizeConnect.php ├── Jobs │ ├── ProcessWebhook.php │ └── FetchUserCredentials.php ├── Models │ ├── StripeEvent.php │ └── StripeAccount.php ├── Assert.php ├── Facades │ └── Stripe.php ├── Connector.php ├── StripeService.php ├── LaravelStripe.php ├── Log │ └── Logger.php └── Console │ └── Commands │ └── StripeQuery.php ├── .github └── workflows │ └── tests.yml ├── phpunit.xml ├── pint.json ├── database ├── factories │ ├── StripeAccountFactory.php │ └── StripeEventFactory.php └── migrations │ └── 2019_07_17_074500_create_stripe_accounts_and_events.php ├── composer.json ├── CHANGELOG.md └── README.md /tests/resources/views/oauth/error.blade.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/resources/views/oauth/success.blade.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /composer.lock 3 | .phpunit.cache/ 4 | -------------------------------------------------------------------------------- /resources/brand/badge/big/powered_by_stripe.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/big/powered_by_stripe.pdf -------------------------------------------------------------------------------- /resources/brand/badge/big/powered_by_stripe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/big/powered_by_stripe.png -------------------------------------------------------------------------------- /resources/brand/badge/big/powered_by_stripe@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/big/powered_by_stripe@2x.png -------------------------------------------------------------------------------- /resources/brand/badge/big/powered_by_stripe@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/big/powered_by_stripe@3x.png -------------------------------------------------------------------------------- /resources/brand/connect-button/blue/blue-on-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/connect-button/blue/blue-on-dark.png -------------------------------------------------------------------------------- /resources/brand/connect-button/blue/blue-on-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/connect-button/blue/blue-on-light.png -------------------------------------------------------------------------------- /resources/brand/connect-button/gray/light-on-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/connect-button/gray/light-on-dark.png -------------------------------------------------------------------------------- /resources/brand/badge/outline-dark/powered_by_stripe.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/outline-dark/powered_by_stripe.pdf -------------------------------------------------------------------------------- /resources/brand/badge/outline-dark/powered_by_stripe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/outline-dark/powered_by_stripe.png -------------------------------------------------------------------------------- /resources/brand/badge/solid-dark/powered_by_stripe.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/solid-dark/powered_by_stripe.pdf -------------------------------------------------------------------------------- /resources/brand/badge/solid-dark/powered_by_stripe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/solid-dark/powered_by_stripe.png -------------------------------------------------------------------------------- /resources/brand/badge/solid-light/powered_by_stripe.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/solid-light/powered_by_stripe.pdf -------------------------------------------------------------------------------- /resources/brand/badge/solid-light/powered_by_stripe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/solid-light/powered_by_stripe.png -------------------------------------------------------------------------------- /resources/brand/connect-button/blue/blue-on-dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/connect-button/blue/blue-on-dark@2x.png -------------------------------------------------------------------------------- /resources/brand/connect-button/blue/blue-on-dark@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/connect-button/blue/blue-on-dark@3x.png -------------------------------------------------------------------------------- /resources/brand/connect-button/blue/blue-on-light@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/connect-button/blue/blue-on-light@2x.png -------------------------------------------------------------------------------- /resources/brand/connect-button/blue/blue-on-light@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/connect-button/blue/blue-on-light@3x.png -------------------------------------------------------------------------------- /resources/brand/connect-button/gray/light-on-dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/connect-button/gray/light-on-dark@2x.png -------------------------------------------------------------------------------- /resources/brand/connect-button/gray/light-on-dark@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/connect-button/gray/light-on-dark@3x.png -------------------------------------------------------------------------------- /resources/brand/connect-button/gray/light-on-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/connect-button/gray/light-on-light.png -------------------------------------------------------------------------------- /resources/brand/badge/outline-light/powered_by_stripe.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/outline-light/powered_by_stripe.pdf -------------------------------------------------------------------------------- /resources/brand/badge/outline-light/powered_by_stripe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/outline-light/powered_by_stripe.png -------------------------------------------------------------------------------- /resources/brand/badge/solid-dark/powered_by_stripe@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/solid-dark/powered_by_stripe@2x.png -------------------------------------------------------------------------------- /resources/brand/badge/solid-dark/powered_by_stripe@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/solid-dark/powered_by_stripe@3x.png -------------------------------------------------------------------------------- /resources/brand/badge/solid-light/powered_by_stripe@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/solid-light/powered_by_stripe@2x.png -------------------------------------------------------------------------------- /resources/brand/badge/solid-light/powered_by_stripe@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/solid-light/powered_by_stripe@3x.png -------------------------------------------------------------------------------- /resources/brand/connect-button/gray/light-on-light@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/connect-button/gray/light-on-light@2x.png -------------------------------------------------------------------------------- /resources/brand/connect-button/gray/light-on-light@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/connect-button/gray/light-on-light@3x.png -------------------------------------------------------------------------------- /resources/brand/badge/outline-dark/powered_by_stripe@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/outline-dark/powered_by_stripe@2x.png -------------------------------------------------------------------------------- /resources/brand/badge/outline-dark/powered_by_stripe@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/outline-dark/powered_by_stripe@3x.png -------------------------------------------------------------------------------- /resources/brand/badge/outline-light/powered_by_stripe@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/outline-light/powered_by_stripe@2x.png -------------------------------------------------------------------------------- /resources/brand/badge/outline-light/powered_by_stripe@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcreativity/laravel-stripe/HEAD/resources/brand/badge/outline-light/powered_by_stripe@3x.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.{php,json}] 11 | indent_size = 4 12 | 13 | [*.{md,yml}] 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /docs/console.md: -------------------------------------------------------------------------------- 1 | # Console 2 | 3 | ## `stripe:query` 4 | 5 | You can query Stripe resources using the `stripe:query` Artisan command. 6 | For example, to query charges on your application's account: 7 | 8 | ```bash 9 | $ php artisan stripe:query charge 10 | ``` 11 | 12 | Or to query a specific charge on a connected account: 13 | 14 | ```bash 15 | $ php artisan stripe:query charge ch_4X8JtIYiSwHJ0o --account=acct_hrGMqodSZxqRuTM1 16 | ``` 17 | 18 | The options available are: 19 | 20 | ``` 21 | Usage: 22 | stripe:query [options] [--] [] 23 | 24 | Arguments: 25 | resource The resource name 26 | id The resource id 27 | 28 | Options: 29 | -A, --account[=ACCOUNT] The connected account 30 | -e, --expand[=EXPAND] The paths to expand (multiple values allowed) 31 | ``` 32 | 33 | > This command is provided for debugging data in your Stripe API. 34 | -------------------------------------------------------------------------------- /src/Exceptions/UnexpectedValueException.php: -------------------------------------------------------------------------------- 1 | stripeClient = $client; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Contracts/Connect/AccountOwnerInterface.php: -------------------------------------------------------------------------------- 1 | account = $account; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Repositories/Concerns/HasMetadata.php: -------------------------------------------------------------------------------- 1 | param( 35 | AbstractRepository::PARAM_METADATA, 36 | collect($meta)->toArray(), 37 | ); 38 | 39 | return $this; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ main, develop ] 6 | pull_request: 7 | branches: [ main, develop ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | fail-fast: true 16 | matrix: 17 | php: [8.2, 8.3, 8.4] 18 | laravel: [11, 12] 19 | 20 | steps: 21 | - name: Checkout Code 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup PHP 25 | uses: shivammathur/setup-php@v2 26 | with: 27 | php-version: ${{ matrix.php }} 28 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, gd 29 | tools: composer:v2 30 | coverage: none 31 | ini-values: error_reporting=E_ALL 32 | 33 | - name: Set Laravel Version 34 | run: composer require "laravel/framework:^${{ matrix.laravel }}" --no-update 35 | 36 | - name: Install dependencies 37 | uses: nick-fields/retry@v3 38 | with: 39 | timeout_minutes: 5 40 | max_attempts: 5 41 | command: composer update --prefer-dist --no-interaction --no-progress 42 | 43 | - name: Execute style 44 | run: composer run style 45 | 46 | - name: Execute tests 47 | run: composer run test 48 | -------------------------------------------------------------------------------- /src/Repositories/Concerns/Update.php: -------------------------------------------------------------------------------- 1 | params($params); 35 | 36 | return $this->send( 37 | 'update', 38 | $id, 39 | $this->params ?: null, 40 | $this->options ?: null, 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/lib/TestAccount.php: -------------------------------------------------------------------------------- 1 | send('retrieve', $this->options ?: null); 35 | } 36 | 37 | protected function fqn(): string 38 | { 39 | return Balance::class; 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | ./tests/lib/Unit/ 20 | 21 | 22 | ./tests/lib/Integration/ 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | src/ 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/Repositories/Concerns/Retrieve.php: -------------------------------------------------------------------------------- 1 | param(self::PARAM_ID, $id); 38 | 39 | return $this->send('retrieve', $this->params, $this->options ?: null); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "psr12", 3 | "rules": { 4 | "array_syntax": true, 5 | "assign_null_coalescing_to_coalesce_equal": true, 6 | "clean_namespace": true, 7 | "declare_strict_types": false, 8 | "list_syntax": true, 9 | "no_empty_phpdoc": true, 10 | "no_superfluous_elseif": true, 11 | "no_superfluous_phpdoc_tags": { 12 | "remove_inheritdoc": true 13 | }, 14 | "no_unneeded_braces": true, 15 | "no_useless_nullsafe_operator": true, 16 | "no_whitespace_before_comma_in_array": true, 17 | "normalize_index_brace": true, 18 | "nullable_type_declaration": true, 19 | "nullable_type_declaration_for_default_null_value": true, 20 | "ordered_attributes": true, 21 | "ordered_traits": true, 22 | "ordered_types": { 23 | "null_adjustment": "always_last" 24 | }, 25 | "phpdoc_summary": true, 26 | "phpdoc_trim": true, 27 | "phpdoc_types": true, 28 | "phpdoc_types_order": { 29 | "null_adjustment": "always_last" 30 | }, 31 | "simple_to_complex_string_variable": true, 32 | "ternary_to_null_coalescing": true, 33 | "trailing_comma_in_multiline": { 34 | "elements": ["arguments", "arrays", "match", "parameters"] 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Exceptions/AccountNotConnectedException.php: -------------------------------------------------------------------------------- 1 | accountId = $accountId; 37 | } 38 | 39 | /** 40 | * @return string 41 | */ 42 | public function accountId() 43 | { 44 | return $this->accountId; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Listeners/DispatchAuthorizeJob.php: -------------------------------------------------------------------------------- 1 | code, 38 | $event->scope, 39 | $event->owner, 40 | ); 41 | 42 | $job->onQueue($config['queue'])->onConnection($config['connection']); 43 | 44 | dispatch($job); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Listeners/LogClientRequests.php: -------------------------------------------------------------------------------- 1 | log = $log; 37 | } 38 | 39 | /** 40 | * Handle the event. 41 | * 42 | * @return void 43 | */ 44 | public function handle(ClientWillSend $event) 45 | { 46 | $this->log->log( 47 | "Sending {$event->name}.{$event->method}", 48 | $event->toArray(), 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Listeners/RemoveAccountOnDeauthorize.php: -------------------------------------------------------------------------------- 1 | adapter = $adapter; 37 | } 38 | 39 | /** 40 | * Handle the event. 41 | * 42 | * @return void 43 | */ 44 | public function handle(AccountDeauthorized $event) 45 | { 46 | $this->adapter->remove($event->account); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /database/factories/StripeAccountFactory.php: -------------------------------------------------------------------------------- 1 | define(StripeAccount::class, function (Faker $faker) { 27 | return [ 28 | 'id' => $faker->unique()->lexify('acct_????????????????'), 29 | 'country' => $faker->randomElement(['AU', 'GB', 'US']), 30 | 'default_currency' => $faker->randomElement(Config::currencies()->all()), 31 | 'details_submitted' => $faker->boolean(75), 32 | 'email' => $faker->email(), 33 | 'payouts_enabled' => $faker->boolean(75), 34 | 'type' => $faker->randomElement(['standard', 'express', 'custom']), 35 | ]; 36 | }); 37 | -------------------------------------------------------------------------------- /tests/database/migrations/2019_07_16_000000_create_test_tables.php: -------------------------------------------------------------------------------- 1 | string('id')->primary(); 34 | $table->timestamps(); 35 | $table->string('name'); 36 | }); 37 | } 38 | 39 | /** 40 | * Reverse the migration. 41 | * 42 | * @return void 43 | */ 44 | public function down() 45 | { 46 | Schema::dropIfExists('test_accounts'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/lib/Integration/Webhooks/WebhookTest.php: -------------------------------------------------------------------------------- 1 | stub('webhook')); 31 | $model = factory(StripeEvent::class)->create(); 32 | 33 | $event = new Webhook($webhook, $model); 34 | 35 | $serialized = unserialize(serialize($event)); 36 | 37 | $this->assertEquals($event->webhook, $webhook, 'same webhook'); 38 | $this->assertTrue($model->is($serialized->model), 'same model'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/database/factories/TestFactory.php: -------------------------------------------------------------------------------- 1 | define(TestAccount::class, function (Faker $faker) { 28 | return [ 29 | 'id' => $faker->unique()->lexify('acct_????????????'), 30 | 'name' => $faker->company(), 31 | ]; 32 | }); 33 | 34 | $factory->define(TestUser::class, function (Faker $faker) { 35 | static $password; 36 | 37 | return [ 38 | 'name' => $faker->name(), 39 | 'email' => $faker->unique()->safeEmail(), 40 | 'password' => $password ?: $password = bcrypt('secret'), 41 | 'remember_token' => Str::random(10), 42 | ]; 43 | }); 44 | -------------------------------------------------------------------------------- /tests/database/migrations/2014_10_12_000000_create_users_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 34 | $table->string('name'); 35 | $table->string('email')->unique(); 36 | $table->string('password'); 37 | $table->rememberToken(); 38 | $table->timestamps(); 39 | }); 40 | } 41 | /** 42 | * Reverse the migrations. 43 | * 44 | * @return void 45 | */ 46 | public function down() 47 | { 48 | Schema::dropIfExists('users'); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Events/ClientWillSend.php: -------------------------------------------------------------------------------- 1 | name = $name; 46 | $this->method = $method; 47 | $this->args = $args; 48 | } 49 | 50 | /** 51 | * @return array 52 | */ 53 | public function toArray() 54 | { 55 | return [ 56 | 'name' => $this->name, 57 | 'method' => $this->method, 58 | 'args' => $this->args, 59 | ]; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Repositories/Concerns/All.php: -------------------------------------------------------------------------------- 1 | params($params); 41 | 42 | return $this->send('all', $this->params ?: null, $this->options ?: null); 43 | } 44 | 45 | /** 46 | * Query all resources, and return a Laravel collection. 47 | * 48 | * @param array|iterable $params 49 | */ 50 | public function collect($params = []): IlluminateCollection 51 | { 52 | return collect($this->all($params)); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /database/factories/StripeEventFactory.php: -------------------------------------------------------------------------------- 1 | define(StripeEvent::class, function (Faker $faker) { 27 | return [ 28 | 'id' => $faker->unique()->lexify('evt_????????????????'), 29 | 'api_version' => $faker->date(), 30 | 'created' => $faker->dateTimeBetween('-1 hour', 'now'), 31 | 'livemode' => $faker->boolean(), 32 | 'pending_webhooks' => $faker->numberBetween(0, 100), 33 | 'type' => $faker->randomElement([ 34 | 'charge.failed', 35 | 'payment_intent.succeeded', 36 | ]), 37 | ]; 38 | }); 39 | 40 | $factory->state(StripeEvent::class, 'connect', function () { 41 | return [ 42 | 'account_id' => factory(StripeAccount::class), 43 | ]; 44 | }); 45 | -------------------------------------------------------------------------------- /src/Events/FetchedUserCredentials.php: -------------------------------------------------------------------------------- 1 | account = $account; 51 | $this->owner = $owner; 52 | $this->token = $token; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/lib/TestWebhookJob.php: -------------------------------------------------------------------------------- 1 | webhook = $webhook; 47 | } 48 | 49 | /** 50 | * Execute the job. 51 | * 52 | * @return void 53 | */ 54 | public function handle() 55 | { 56 | // noop 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Connect/SessionState.php: -------------------------------------------------------------------------------- 1 | session = $session; 43 | $this->request = $request; 44 | } 45 | 46 | public function get() 47 | { 48 | return $this->session->token(); 49 | } 50 | 51 | public function check($value) 52 | { 53 | return $this->get() === $value; 54 | } 55 | 56 | public function user() 57 | { 58 | return $this->request->user(); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/Listeners/LogClientResults.php: -------------------------------------------------------------------------------- 1 | log = $log; 35 | } 36 | 37 | /** 38 | * Handle the event. 39 | * 40 | * @return void 41 | */ 42 | public function handle(ClientReceivedResult $event) 43 | { 44 | $message = "Result for {$event->name}.{$event->method}"; 45 | $context = $event->toArray(); 46 | 47 | if (!$event->result instanceof JsonSerializable) { 48 | $this->log->log($message, $context); 49 | return; 50 | } 51 | 52 | unset($context['result']); 53 | $this->log->encode($message, $event->result, $context); 54 | } 55 | 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/Repositories/PaymentIntentRepository.php: -------------------------------------------------------------------------------- 1 | params($params)->params( 46 | compact('currency', 'amount'), 47 | ); 48 | 49 | return $this->send('create', $this->params, $this->options); 50 | } 51 | 52 | protected function fqn(): string 53 | { 54 | return PaymentIntent::class; 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/Repositories/ChargeRepository.php: -------------------------------------------------------------------------------- 1 | params($params)->params( 45 | compact('currency', 'amount'), 46 | ); 47 | 48 | return $this->send( 49 | 'create', 50 | $this->params ?: null, 51 | $this->options ?: null, 52 | ); 53 | } 54 | 55 | protected function fqn(): string 56 | { 57 | return Charge::class; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Webhooks/Verifier.php: -------------------------------------------------------------------------------- 1 | header(self::SIGNATURE_HEADER)) { 42 | throw SignatureVerificationException::factory( 43 | 'Expecting ' . self::SIGNATURE_HEADER . ' header.', 44 | $request->getContent(), 45 | $header, 46 | ); 47 | } 48 | 49 | WebhookSignature::verifyHeader( 50 | $request->getContent(), 51 | $header, 52 | Config::webhookSigningSecrect($name), 53 | Config::webhookTolerance(), 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Events/ClientReceivedResult.php: -------------------------------------------------------------------------------- 1 | name = $name; 55 | $this->method = $method; 56 | $this->args = $args; 57 | $this->result = $result; 58 | } 59 | 60 | /** 61 | * @return array 62 | */ 63 | public function toArray() 64 | { 65 | return [ 66 | 'name' => $this->name, 67 | 'method' => $this->method, 68 | 'args' => $this->args, 69 | 'result' => $this->result, 70 | ]; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/lib/Integration/EloquentTest.php: -------------------------------------------------------------------------------- 1 | set('stripe.connect.model', TestAccount::class); 30 | } 31 | 32 | public function test() 33 | { 34 | /** @var TestAccount $model */ 35 | $model = factory(TestAccount::class)->create(); 36 | 37 | $this->assertSame($model->getKeyName(), $model->getStripeAccountIdentifierName(), 'key name'); 38 | $this->assertSame($model->getKey(), $model->getStripeAccountIdentifier(), 'key'); 39 | $this->assertTrue($model->stripe()->is($model), 'model connector'); 40 | $this->assertTrue(Stripe::connect($model->id)->is($model), 'facade account connector'); 41 | } 42 | 43 | public function testIncrementing() 44 | { 45 | /** @var TestAccount $model */ 46 | $model = factory(TestAccount::class)->make(); 47 | $model->incrementing = true; 48 | 49 | $this->assertSame('stripe_account_id', $model->getStripeAccountIdentifierName()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Events/SignatureVerificationFailed.php: -------------------------------------------------------------------------------- 1 | message = $message; 53 | $this->header = $header; 54 | $this->signingSecret = $signingSecret; 55 | } 56 | 57 | /** 58 | * @return string 59 | */ 60 | public function signingSecret() 61 | { 62 | return Config::webhookSigningSecrect($this->signingSecret); 63 | } 64 | 65 | public function toArray() 66 | { 67 | return [ 68 | 'message' => $this->message, 69 | 'header' => $this->header, 70 | 'signing_secret' => $this->signingSecret, 71 | ]; 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/Connect/OwnsStripeAccounts.php: -------------------------------------------------------------------------------- 1 | {$this->getStripeIdentifierName()}; 38 | } 39 | 40 | /** 41 | * Get the column name of the unique identifier for the Stripe account owner. 42 | */ 43 | public function getStripeIdentifierName(): string 44 | { 45 | if ($this instanceof Authenticatable) { 46 | return $this->getAuthIdentifierName(); 47 | } 48 | 49 | return $this->getKeyName(); 50 | } 51 | 52 | public function stripeAccounts(): HasMany 53 | { 54 | $model = Config::connectModel(); 55 | 56 | if (!$owner = $model->getStripeOwnerIdentifierName()) { 57 | throw new LogicException('Stripe account model must have an owner column.'); 58 | } 59 | 60 | return $this->hasMany( 61 | get_class($model), 62 | $owner, 63 | $this->getStripeIdentifierName(), 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Listeners/DispatchWebhookJob.php: -------------------------------------------------------------------------------- 1 | queue = $queue; 44 | $this->log = $log; 45 | } 46 | 47 | /** 48 | * Handle the event. 49 | * 50 | * @return void 51 | */ 52 | public function handle(Webhook $webhook) 53 | { 54 | if (!$job = $webhook->job()) { 55 | return; 56 | } 57 | 58 | /** @var Queueable $job */ 59 | $job = new $job($webhook); 60 | $job->onConnection($webhook->connection()); 61 | $job->onQueue($webhook->queue()); 62 | 63 | $this->log->log("Dispatching job for webhook '{$webhook->type()}'.", [ 64 | 'id' => $webhook->id(), 65 | 'connection' => $webhook->connection(), 66 | 'queue' => $webhook->queue(), 67 | 'job' => $webhook->job(), 68 | ]); 69 | 70 | $this->queue->dispatch($job); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudcreativity/laravel-stripe", 3 | "description": "Laravel integration for Stripe, including Stripe Connect.", 4 | "type": "library", 5 | "license": "Apache-2.0", 6 | "authors": [ 7 | { 8 | "name": "Christopher Gammie", 9 | "email": "info@cloudcreativity.co.uk" 10 | } 11 | ], 12 | "minimum-stability": "stable", 13 | "prefer-stable": true, 14 | "require": { 15 | "php": "^8.2", 16 | "ext-json": "*", 17 | "illuminate/console": "^11.0|^12.0", 18 | "illuminate/contracts": "^11.0|^12.0", 19 | "illuminate/database": "^11.0|^12.0", 20 | "illuminate/http": "^11.0|^12.0", 21 | "illuminate/queue": "^11.0|^12.0", 22 | "illuminate/routing": "^11.0|^12.0", 23 | "illuminate/support": "^11.0|^12.0", 24 | "psr/log": "^3.0", 25 | "stripe/stripe-php": "^16.2" 26 | }, 27 | "require-dev": { 28 | "laravel/cashier": "^15.6", 29 | "laravel/legacy-factories": "^1.4", 30 | "laravel/pint": "^1.24", 31 | "orchestra/testbench": "^9.14|^10.0", 32 | "phpunit/phpunit": "^11.5" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "CloudCreativity\\LaravelStripe\\": "src/" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "CloudCreativity\\LaravelStripe\\Tests\\": "tests/lib" 42 | } 43 | }, 44 | "scripts": { 45 | "all": [ 46 | "@style", 47 | "@test" 48 | ], 49 | "style": "pint --test", 50 | "style:fix": "pint", 51 | "test": "phpunit" 52 | }, 53 | "extra": { 54 | "branch-alias": { 55 | "dev-develop": "1.x-dev" 56 | }, 57 | "laravel": { 58 | "providers": [ 59 | "CloudCreativity\\LaravelStripe\\ServiceProvider" 60 | ], 61 | "aliases": { 62 | "Stripe": "CloudCreativity\\LaravelStripe\\Facades\\Stripe" 63 | } 64 | } 65 | }, 66 | "config": { 67 | "sort-packages": true 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Events/OAuthSuccess.php: -------------------------------------------------------------------------------- 1 | code = $code; 49 | $this->scope = $scope; 50 | } 51 | 52 | /** 53 | * Is the scope read only? 54 | * 55 | * @return bool 56 | */ 57 | public function readOnly() 58 | { 59 | return Authorizer::SCOPE_READ_ONLY === $this->scope; 60 | } 61 | 62 | /** 63 | * Is the scope read/write? 64 | * 65 | * @return bool 66 | */ 67 | public function readWrite() 68 | { 69 | return Authorizer::SCOPE_READ_WRITE === $this->scope; 70 | } 71 | 72 | protected function defaults() 73 | { 74 | return ['scope' => $this->scope]; 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | events = $events; 40 | } 41 | 42 | /** 43 | * @param string $class 44 | * @param string $method 45 | */ 46 | public function __invoke($class, $method, ...$args) 47 | { 48 | if (!is_callable("{$class}::{$method}")) { 49 | throw new InvalidArgumentException(sprintf('Cannot class %s method %s', $class, $method)); 50 | } 51 | 52 | $name = Str::snake(class_basename($class)); 53 | 54 | $this->events->dispatch(new ClientWillSend($name, $method, $args)); 55 | 56 | $result = $this->execute($class, $method, $args); 57 | 58 | $this->events->dispatch(new ClientReceivedResult($name, $method, $args, $result)); 59 | 60 | return $result; 61 | } 62 | 63 | /** 64 | * Execute the static Stripe call. 65 | */ 66 | protected function execute($class, $method, array $args) 67 | { 68 | return call_user_func_array("{$class}::{$method}", $args); 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/Repositories/AccountRepository.php: -------------------------------------------------------------------------------- 1 | params($params)->param('type', $type); 42 | 43 | return $this->send('create', $this->params ?: null, $this->options ?: null); 44 | } 45 | 46 | /** 47 | * Retrieve a Stripe account. 48 | * 49 | * If the id is not provided, the account associated with this 50 | * repository is returned. 51 | */ 52 | public function retrieve(?string $id = null): Account 53 | { 54 | if (!is_string($id) && !is_null($id)) { 55 | throw new InvalidArgumentException('Expecting a string or null.'); 56 | } 57 | 58 | if ($id) { 59 | $this->param('id', $id); 60 | } 61 | 62 | return $this->send('retrieve', $this->params ?: null, $this->options ?: null); 63 | } 64 | 65 | protected function fqn(): string 66 | { 67 | return Account::class; 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /src/Contracts/Connect/AccountInterface.php: -------------------------------------------------------------------------------- 1 | log = $log; 41 | } 42 | 43 | /** 44 | * Handle a Stripe webhook. 45 | * 46 | * @return Response 47 | */ 48 | public function __invoke(Request $request, ProcessorInterface $processor) 49 | { 50 | if ('event' !== $request->json('object') || empty($request->json('id'))) { 51 | $this->log->log("Invalid Stripe webhook payload."); 52 | 53 | return response()->json(['error' => 'Invalid JSON payload.'], Response::HTTP_BAD_REQUEST); 54 | } 55 | 56 | $event = Event::constructFrom($request->json()->all()); 57 | 58 | /** Only process the webhook if it has not already been processed. */ 59 | if ($processor->didReceive($event)) { 60 | $this->log->log(sprintf( 61 | "Ignoring Stripe webhook %s for event %s, as it is already processed.", 62 | $event->id, 63 | $event->type, 64 | )); 65 | } else { 66 | $this->log->encode("Received new Stripe webhook event {$event->type}", $event); 67 | $processor->receive($event); 68 | } 69 | 70 | return response('', Response::HTTP_NO_CONTENT); 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/Jobs/ProcessWebhook.php: -------------------------------------------------------------------------------- 1 | event = $event; 58 | $this->payload = $payload; 59 | } 60 | 61 | /** 62 | * Execute the job. 63 | * 64 | * @return void 65 | * @throws \Throwable 66 | */ 67 | public function handle(ProcessorInterface $processor, Logger $log) 68 | { 69 | $webhook = Event::constructFrom($this->payload); 70 | 71 | $log->log( 72 | "Processing webhook {$webhook->id}.", 73 | collect($this->payload)->only('account', 'type')->all(), 74 | ); 75 | 76 | $this->event->getConnection()->transaction(function () use ($processor, $webhook) { 77 | $processor->dispatch($webhook, $this->event); 78 | }); 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /src/Jobs/FetchUserCredentials.php: -------------------------------------------------------------------------------- 1 | code = $code; 61 | $this->scope = $scope; 62 | $this->owner = $owner; 63 | } 64 | 65 | /** 66 | * Execute the job. 67 | * 68 | * @return void 69 | */ 70 | public function handle(Authorizer $authorizer, AdapterInterface $adapter) 71 | { 72 | $token = $authorizer->authorize($this->code); 73 | 74 | $account = $adapter->store( 75 | $token['stripe_user_id'], 76 | $token['refresh_token'], 77 | $token['scope'], 78 | $this->owner, 79 | ); 80 | 81 | event(new FetchedUserCredentials($account, $this->owner, $token)); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Connect/Connector.php: -------------------------------------------------------------------------------- 1 | account = $account; 44 | } 45 | 46 | /** 47 | * Is the connector for the provided account? 48 | * 49 | * @param AccountInterface|string $accountId 50 | * @return bool 51 | */ 52 | public function is($accountId) 53 | { 54 | if ($accountId instanceof AccountInterface) { 55 | $accountId = $accountId->getStripeAccountIdentifier(); 56 | } 57 | 58 | return $this->id() === $accountId; 59 | } 60 | 61 | /** 62 | * @return string 63 | */ 64 | public function id() 65 | { 66 | return $this->account->getStripeAccountIdentifier(); 67 | } 68 | 69 | /** 70 | * Deauthorize the connected account. 71 | * 72 | * @param array|iterable|null $options 73 | * @return void 74 | */ 75 | public function deauthorize($options = null) 76 | { 77 | app(Authorizer::class)->deauthorize( 78 | $this->accountId(), 79 | collect($options)->all() ?: null, 80 | ); 81 | 82 | event(new AccountDeauthorized($this->account)); 83 | } 84 | 85 | protected function accountId(): string 86 | { 87 | return $this->id(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Events/AbstractOAuthEvent.php: -------------------------------------------------------------------------------- 1 | owner = $owner; 66 | $this->view = $view; 67 | $this->data = $data; 68 | } 69 | 70 | /** 71 | * @param array|string $key 72 | * @param mixed|null $value 73 | * @return $this 74 | */ 75 | public function with($key, $value = null) 76 | { 77 | if (is_array($key)) { 78 | $this->data = array_merge($this->data, $key); 79 | } else { 80 | $this->data[$key] = $value; 81 | } 82 | 83 | return $this; 84 | } 85 | 86 | /** 87 | * Get all view data. 88 | * 89 | * @return array 90 | */ 91 | public function all() 92 | { 93 | return collect($this->data) 94 | ->merge($this->defaults()) 95 | ->put('owner', $this->owner) 96 | ->all(); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Http/Middleware/VerifySignature.php: -------------------------------------------------------------------------------- 1 | verifier = $verifier; 51 | $this->events = $events; 52 | $this->log = $log; 53 | } 54 | 55 | /** 56 | * @param string $signingSecret 57 | */ 58 | public function handle($request, \Closure $next, $signingSecret = 'default') 59 | { 60 | $this->log->log("Verifying Stripe webhook using signing secret: {$signingSecret}"); 61 | 62 | try { 63 | $this->verifier->verify($request, $signingSecret); 64 | } catch (SignatureVerificationException $ex) { 65 | $event = new SignatureVerificationFailed( 66 | $ex->getMessage(), 67 | $ex->getSigHeader(), 68 | $signingSecret, 69 | ); 70 | 71 | $this->log->log("Stripe webhook signature verification failed.", $event->toArray()); 72 | $this->events->dispatch($event); 73 | 74 | return response()->json(['error' => 'Invalid signature.'], Response::HTTP_BAD_REQUEST); 75 | } 76 | 77 | $this->log->log("Verified Stripe webhook with signing secret: {$signingSecret}"); 78 | 79 | return $next($request); 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/Models/StripeEvent.php: -------------------------------------------------------------------------------- 1 | 'datetime', 53 | 'livemode' => 'boolean', 54 | 'pending_webhooks' => 'integer', 55 | 'request' => 'json', 56 | ]; 57 | 58 | /** 59 | * @return BelongsTo 60 | */ 61 | public function account() 62 | { 63 | $model = Config::connectModel(); 64 | 65 | return new BelongsTo( 66 | $model->newQuery(), 67 | $this, 68 | $this->getAccountIdentifierName(), 69 | $model->getStripeAccountIdentifierName(), 70 | 'account', 71 | ); 72 | } 73 | 74 | /** 75 | * @return string 76 | */ 77 | public function getAccountIdentifierName() 78 | { 79 | return 'account_id'; 80 | } 81 | 82 | /** 83 | * Get the Stripe connector for the account that this belongs to. 84 | * 85 | * @return Connector 86 | * @throws AccountNotConnectedException 87 | */ 88 | public function stripe() 89 | { 90 | if ($account = $this->account_id) { 91 | return app('stripe')->connect($account); 92 | } 93 | 94 | return app('stripe')->account(); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Events/OAuthError.php: -------------------------------------------------------------------------------- 1 | error = $code; 87 | $this->message = $description; 88 | } 89 | 90 | protected function defaults() 91 | { 92 | return ['error' => $this->error, 'message' => $this->message]; 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /src/Testing/Concerns/MakesStripeAssertions.php: -------------------------------------------------------------------------------- 1 | stripeClient->queue(...$objects); 41 | } 42 | 43 | /** 44 | * Assert the next Stripe call in the history. 45 | * 46 | * @param $class 47 | * the expected fully qualified class name. 48 | * @param $method 49 | * the expected static method. 50 | * @param Closure|null $args 51 | * an optional closure to assert that the call received the correct arguments. 52 | */ 53 | public function assertInvoked($class, $method, ?Closure $args = null) 54 | { 55 | $index = $this->stripeClient->increment(); 56 | 57 | $this->assertInvokedAt($index, $class, $method, $args); 58 | } 59 | 60 | /** 61 | * Assert the next Stripe call in the history. 62 | * 63 | * @param int $index 64 | * the index in the history of Stripe calls. 65 | * @param $class 66 | * the expected fully qualified class name. 67 | * @param $method 68 | * the expected static method. 69 | * @param Closure|null $args 70 | * an optional closure to assert that the call received the correct arguments. 71 | */ 72 | public function assertInvokedAt($index, $class, $method, ?Closure $args = null) 73 | { 74 | if (!$history = $this->stripeClient->at($index)) { 75 | Assert::fail("No Stripe call at index {$index}."); 76 | } 77 | 78 | Assert::assertSame( 79 | $class . '::' . $method, 80 | $history['class'] . '::' . $history['method'], 81 | "Stripe {$index}: class and method", 82 | ); 83 | 84 | if ($args) { 85 | Assert::assertTrue( 86 | $args(...$history['args']), 87 | "Stripe {$index}: arguments", 88 | ); 89 | } 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. This project adheres to 4 | [Semantic Versioning](http://semver.org/) and [this changelog format](http://keepachangelog.com/). 5 | 6 | ## Unreleased 7 | 8 | ## [0.8.0] - 2025-09-12 9 | 10 | ### Changed 11 | 12 | - Minimum PHP version is now 8.2, previously was 8.1. 13 | - Upgraded to Laravel 11 and 12, dropping support for Laravel 10. 14 | 15 | ## [0.7.1] - 2024-09-01 16 | 17 | ### Removed 18 | 19 | - Removed checking the prefix of account and charge ids, as Stripe does not consider changing these as 20 | a [breaking change.](https://docs.stripe.com/upgrades#what-changes-does-stripe-consider-to-be-backwards-compatible) 21 | This was causing issues in the refund repository, as it was expecting a charge id starting `ch_`. However, Stripe now 22 | also uses `py_` for some refundable payments. 23 | 24 | ## [0.7.0] - 2023-03-19 25 | 26 | ### Changed 27 | 28 | - Minimum PHP version is now 8.1. 29 | - Upgraded to Laravel 10, dropping support for Laravel 8 and 9. 30 | 31 | ## [0.6.0] - 2022-02-18 32 | 33 | ### Added 34 | 35 | - Package now supports Laravel 9. 36 | 37 | ### Changed 38 | 39 | - Minimum PHP version is now PHP 7.4. 40 | 41 | ## [0.5.2] - 2022-02-18 42 | 43 | ### Fixed 44 | 45 | - [#12](https://github.com/cloudcreativity/laravel-stripe/issues/12) Fixed oAuth process note returning a scope for a 46 | Stripe Express account. 47 | 48 | ## [0.5.1] - 2021-03-17 49 | 50 | ### Added 51 | 52 | - Package now supports PHP 8 (in addition to `^7.3`). 53 | 54 | ## [0.5.0] - 2020-09-09 55 | 56 | ### Changed 57 | 58 | - Minimum PHP version is now 7.3. 59 | - Minimum Laravel version is now 8.0. 60 | 61 | ## [0.4.0] - 2020-09-09 62 | 63 | ### Added 64 | 65 | - Added balance repository. 66 | - The `stripe:query` Artisan command now accepts resource names in either singular or plural form. 67 | 68 | ### Fixed 69 | 70 | - **BREAKING:** The Stripe accounts relationship on the `Connect\OwnsStripeAccounts` trait now correctly uses 71 | the `Contracts\Connect\AccountOwnerInterface::getStripeIdentifierName()` method to determine the column name on the 72 | inverse model. This means the column name now defaults to `owner_id`. This change could potentially break 73 | implementations. If you use a different column from `owner_id`, then overload the `getStripeIdentifierName()` method 74 | on the model that owns Stripe accounts. 75 | - Fixed catching API exceptions in the `stripe:query` Artisan command. 76 | 77 | ## [0.3.0] - 2020-07-27 78 | 79 | ### Changed 80 | 81 | - Minimum PHP version is now `7.2.5`. 82 | - Minimum Laravel version is now `7.x`. 83 | - Minimum Stripe PHP version is now `7.0`. 84 | 85 | ## [0.2.0] - 2020-06-17 86 | 87 | Release for Laravel `5.5`, `5.6`, `5.7`, `5.8` and `6.x`. 88 | 89 | ## [0.1.1] - 2020-01-04 90 | 91 | ### Fixed 92 | 93 | - [#3](git@github.com:cloudcreativity/laravel-stripe.git) 94 | Fix facade alias in Composer json. 95 | 96 | ## [0.1.0] - 2019-08-12 97 | 98 | Initial release for PHP 5.6 / Laravel 5.4. 99 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | Test calls to the Stripe API via our test helpers. 4 | 5 | ## Usage 6 | 7 | You may use the Stripe facade's `fake()` method to prevent all static calls to the 8 | `stripe/stripe-php` library from executing. This prevents any requests being sent to Stripe 9 | in your tests. 10 | 11 | You can then assert that static calls were made and even inspect the arguments they received. 12 | For this to work, you need to tell the fake what objects to return (and in what order) 13 | **before** the code under test is executed, and then make the assertions **after** the 14 | test code is executed. 15 | 16 | For example: 17 | 18 | ```php 19 | namespace Tests\Feature; 20 | 21 | use Tests\TestCase; 22 | use CloudCreativity\LaravelStripe\Facades\Stripe; 23 | 24 | class StripeTest extends TestCase 25 | { 26 | 27 | public function test() 28 | { 29 | Stripe::fake( 30 | $expected = new \Stripe\PaymentIntent() 31 | ); 32 | 33 | $account = factory(StripeAccount::class)->create(); 34 | $actual = $account->stripe()->paymentIntents()->create('gbp', 999); 35 | 36 | $this->assertSame($expected, $actual); 37 | 38 | Stripe::assertInvoked( 39 | \Stripe\PaymentIntent::class, 40 | 'create', 41 | function ($params, $options) use ($account) { 42 | $this->assertEquals(['currency' => 'gbp', 'amount' => 999], $params); 43 | $this->assertEquals(['stripe_account' => $account->id], $options); 44 | return true; 45 | } 46 | ); 47 | } 48 | } 49 | ``` 50 | 51 | If you are expecting multiple calls, you can queue up multiple return results: 52 | 53 | ```php 54 | Stripe::fake( 55 | new \Stripe\Account(), 56 | new \Stripe\Charge() 57 | ) 58 | ``` 59 | 60 | In this scenario, you need to call `assertInvoked()` in *exactly* the same order 61 | as you were expecting the static calls to be made. 62 | 63 | ## Asserting No Calls 64 | 65 | The Stripe fake fails the test if it is called when it no longer has any queued 66 | results. This means that if you expect Stripe to never be called, all you need 67 | to do is: 68 | 69 | ```php 70 | Stripe::fake() 71 | ``` 72 | 73 | In this scenario, if there is an unexpected call the test will fail. 74 | 75 | ## Non-Static Methods 76 | 77 | Calling `Stripe::fake()` only prevents **static** methods in Stripe's PHP package from being 78 | called. This means you will need to mock any non-static methods. 79 | 80 | For example, it is possible to cancel a payment intent by calling the `cancel()` method 81 | on a `\Stripe\PaymentIntent` instance. To test this, we will need to provide a mock 82 | as the static return result: 83 | 84 | ```php 85 | // Example using PHPUnit mock... 86 | $mock = $this 87 | ->getMockBuilder(\Stripe\PaymentIntent::class) 88 | ->setMethods(['cancel']) 89 | ->getMock(); 90 | 91 | $mock->expects($this->once())->method('cancel'); 92 | 93 | Stripe::fake($mock); 94 | 95 | // ...run test code. 96 | ``` 97 | -------------------------------------------------------------------------------- /tests/lib/Integration/Connect/DeauthorizeTest.php: -------------------------------------------------------------------------------- 1 | create(); 39 | 40 | $account->stripe()->deauthorize(['foo' => 'bar']); 41 | 42 | Stripe::assertInvoked(OAuth::class, 'deauthorize', function ($params, $options) use ($account) { 43 | $this->assertSame(['stripe_user_id' => $account->id], $params, 'params'); 44 | $this->assertSame(['foo' => 'bar'], $options, 'options'); 45 | return true; 46 | }); 47 | 48 | Event::assertDispatched(AccountDeauthorized::class, function ($event) use ($account) { 49 | $this->assertTrue($account->is($event->account), 'event account'); 50 | return true; 51 | }); 52 | } 53 | 54 | public function testDeletesOnEvent() 55 | { 56 | Stripe::fake(new StripeObject()); 57 | 58 | $account = factory(StripeAccount::class)->create([ 59 | 'refresh_token' => 'access_token', 60 | 'token_scope' => 'read_write', 61 | ]); 62 | 63 | Stripe::connect($account)->deauthorize(['foo' => 'bar']); 64 | 65 | Stripe::assertInvoked(OAuth::class, 'deauthorize', function ($params, $options) use ($account) { 66 | $this->assertSame(['stripe_user_id' => $account->id], $params, 'params'); 67 | $this->assertSame(['foo' => 'bar'], $options, 'options'); 68 | return true; 69 | }); 70 | 71 | $this->assertDatabaseHas('stripe_accounts', [ 72 | $account->getKeyName() => $account->getKey(), 73 | 'deleted_at' => Carbon::now()->toDateTimeString(), 74 | 'refresh_token' => null, 75 | 'token_scope' => null, 76 | ]); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Install the package using Composer: 4 | 5 | ```bash 6 | $ composer require cloudcreativity/laravel-stripe:1.x-dev 7 | ``` 8 | 9 | Add the service provider and facade to your app config file: 10 | 11 | ```php 12 | // config/app.php 13 | return [ 14 | // ... 15 | 16 | 'providers' => [ 17 | // ... 18 | CloudCreativity\LaravelStripe\ServiceProvider::class, 19 | ], 20 | 21 | 'aliases' => [ 22 | // ... 23 | 'Stripe' => CloudCreativity\LaravelStripe\Facades\Stripe::class, 24 | ], 25 | ]; 26 | ``` 27 | 28 | Then publish the package config: 29 | 30 | ```bash 31 | $ php artisan vendor:publish --tag=stripe 32 | ``` 33 | 34 | ## Configuration 35 | 36 | Package configuration is in the `stripe.php` config file. That file contains descriptions of 37 | each configuration option, and these options are also referred to in the relevant documentation 38 | chapters. 39 | 40 | Note that by default Laravel puts your Stripe keys in the `services` config file. We expect 41 | them to be there too. Here's an example from Laravel 5.8: 42 | 43 | ```php 44 | return [ 45 | // ...other service config 46 | 47 | 'stripe' => [ 48 | 'model' => \App\User::class, 49 | 'key' => env('STRIPE_KEY'), 50 | 'secret' => env('STRIPE_SECRET'), 51 | 'webhook' => [ 52 | 'secret' => env('STRIPE_WEBHOOK_SECRET'), 53 | 'tolerance' => env('STRIPE_WEBHOOK_TOLERANCE', 300), 54 | ], 55 | ], 56 | ]; 57 | ``` 58 | 59 | ## Migrations 60 | 61 | This package contains a number of migrations for the models it provides. **By default these 62 | are loaded by the package.** 63 | 64 | If you are customising any of the models in our implementation, you will need to disable migrations 65 | and publish the migrations instead. 66 | 67 | First, disable the migrations in the `register()` method of your application's service provider: 68 | 69 | ```php 70 | namespace App\Providers; 71 | 72 | use CloudCreativity\LaravelStripe\LaravelStripe; 73 | use Illuminate\Support\ServiceProvider; 74 | 75 | class AppServiceProvider extends ServiceProvider 76 | { 77 | 78 | public function register() 79 | { 80 | LaravelStripe::withoutMigrations(); 81 | } 82 | } 83 | ``` 84 | 85 | Then publish the migrations: 86 | 87 | ```bash 88 | $ php artisan vendor:publish --tag=stripe-migrations 89 | ``` 90 | 91 | > You must disable migrations **before** attempting to publish them, as they will only be publishable 92 | if migrations are disabled. Plus you must use the `register()` method, not `boot()`. 93 | 94 | ## Brand Assets 95 | 96 | If you want to use *Powered by Stripe* badges, or *Connect with Stripe* buttons, publish 97 | [Stripe brand assets](https://stripe.com/gb/newsroom/brand-assets) using the following command: 98 | 99 | ```bash 100 | $ php artisan vendor:publish --tag=stripe-brand 101 | ``` 102 | 103 | This will publish the files into the `public/vendor/stripe/brand` folder. 104 | -------------------------------------------------------------------------------- /src/Assert.php: -------------------------------------------------------------------------------- 1 | containsStrict($currency)) { 55 | throw new UnexpectedValueException("Expecting a valid currency, received: {$currency}"); 56 | } 57 | } 58 | 59 | /** 60 | * Assert that the currency and amount are chargeable. 61 | * 62 | * @return void 63 | * @see https://stripe.com/docs/currencies#minimum-and-maximum-charge-amounts 64 | */ 65 | public static function chargeAmount($currency, $amount) 66 | { 67 | self::supportedCurrency($currency); 68 | 69 | if (!is_int($amount)) { 70 | throw new UnexpectedValueException('Expecting an integer.'); 71 | } 72 | 73 | $minimum = Config::minimum($currency); 74 | 75 | if ($minimum > $amount) { 76 | throw new UnexpectedValueException("Expecting a charge amount that is greater than {$minimum}."); 77 | } 78 | } 79 | 80 | /** 81 | * Assert that the value is a zero-decimal amount. 82 | * 83 | * @return void 84 | * @see https://stripe.com/docs/currencies#zero-decimal 85 | */ 86 | public static function zeroDecimal($amount) 87 | { 88 | if (!is_int($amount) || 0 > $amount) { 89 | throw new UnexpectedValueException('Expecting a positive integer.'); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Testing/ClientFake.php: -------------------------------------------------------------------------------- 1 | queue = collect(); 51 | $this->history = collect(); 52 | $this->counter = 0; 53 | } 54 | 55 | /** 56 | * Queue results. 57 | * 58 | * @return void 59 | */ 60 | public function queue(StripeObject ...$results) 61 | { 62 | $this->queue = $this->queue->merge($results); 63 | } 64 | 65 | /** 66 | * Get the call history index. 67 | * 68 | * @return int 69 | */ 70 | public function index() 71 | { 72 | return $this->counter; 73 | } 74 | 75 | /** 76 | * Get the current index, then increment it. 77 | * 78 | * @return int 79 | */ 80 | public function increment() 81 | { 82 | $index = $this->index(); 83 | 84 | ++$this->counter; 85 | 86 | return $index; 87 | } 88 | 89 | /** 90 | * @param int $index 91 | * @return array|null 92 | */ 93 | public function at($index) 94 | { 95 | return $this->history->get($index); 96 | } 97 | 98 | /** 99 | * @return StripeObject 100 | */ 101 | protected function execute($class, $method, array $args) 102 | { 103 | if ($this->queue->isEmpty()) { 104 | Assert::fail(("Unexpected Stripe call: {$class}::{$method}")); 105 | } 106 | 107 | $this->history->push([ 108 | 'class' => $class, 109 | 'method' => $method, 110 | 'args' => $args, 111 | 'result' => $result = $this->queue->shift(), 112 | ]); 113 | 114 | return $result; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Webhooks/ConnectWebhook.php: -------------------------------------------------------------------------------- 1 | account = $account; 59 | } 60 | 61 | public function connect() 62 | { 63 | return true; 64 | } 65 | 66 | /** 67 | * @return string|null 68 | */ 69 | public function account() 70 | { 71 | return $this->webhook['account']; 72 | } 73 | 74 | /** 75 | * Is the webhook for the supplied account? 76 | * 77 | * @param AccountInterface|string $account 78 | * @return bool 79 | */ 80 | public function accountIs($account) 81 | { 82 | if ($account instanceof AccountInterface) { 83 | $account = $account->getStripeAccountId(); 84 | } 85 | 86 | return $this->account() === $account; 87 | } 88 | 89 | /** 90 | * Is the webhook not for the specified account? 91 | * 92 | * @param AccountInterface|string $account 93 | * @return bool 94 | */ 95 | public function accountIsNot($account) 96 | { 97 | return !$this->accountIs($account); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Facades/Stripe.php: -------------------------------------------------------------------------------- 1 | instance( 63 | Client::class, 64 | $client = new ClientFake(static::$app->make('events')), 65 | ); 66 | 67 | /** 68 | * We then swap in a Stripe service fake, that has our test assertions on it. 69 | * This extends the real Stripe service and doesn't overload anything on it, 70 | * so the service will operate exactly as expected. 71 | */ 72 | static::swap($fake = new StripeFake($client)); 73 | 74 | $fake->withQueue(...$queue); 75 | } 76 | 77 | /** 78 | * @return string 79 | */ 80 | protected static function getFacadeAccessor() 81 | { 82 | return 'stripe'; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Repositories/RefundRepository.php: -------------------------------------------------------------------------------- 1 | create($charge, $params); 47 | } 48 | 49 | /** 50 | * Create a partial refund. 51 | * 52 | * @param Charge|string $charge 53 | * @param array|iterable $params 54 | */ 55 | public function partial($charge, int $amount, iterable $params = []): Refund 56 | { 57 | Assert::zeroDecimal($amount); 58 | 59 | $params['amount'] = $amount; 60 | 61 | return $this->create($charge, $params); 62 | } 63 | 64 | /** 65 | * Create a refund. 66 | * 67 | * @param Charge|string $charge 68 | * @param array|iterable $params 69 | */ 70 | public function create($charge, iterable $params = []): Refund 71 | { 72 | if ($charge instanceof Charge) { 73 | $charge = $charge->id; 74 | } 75 | 76 | $this->params($params)->param('charge', $charge); 77 | 78 | return $this->send( 79 | 'create', 80 | $this->params ?: null, 81 | $this->options ?: null, 82 | ); 83 | } 84 | 85 | /** 86 | * Update a refund. 87 | * 88 | * This request only accepts the `metadata` as an argument. 89 | * 90 | * @param array|Collection|iterable $metadata 91 | */ 92 | public function update(string $id, iterable $metadata): Refund 93 | { 94 | $this->metadata($metadata); 95 | 96 | return $this->send( 97 | 'update', 98 | $id, 99 | $this->params ?: null, 100 | $this->options ?: null, 101 | ); 102 | } 103 | 104 | protected function fqn(): string 105 | { 106 | return Refund::class; 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /src/Models/StripeAccount.php: -------------------------------------------------------------------------------- 1 | 'json', 66 | 'capabilities' => 'json', 67 | 'charges_enabled' => 'boolean', 68 | 'deleted_at' => 'datetime', 69 | 'details_submitted' => 'boolean', 70 | 'individual' => 'json', 71 | 'metadata' => 'json', 72 | 'payouts_enabled' => 'boolean', 73 | 'requirements' => 'json', 74 | 'settings' => 'json', 75 | 'tos_acceptance' => 'json', 76 | ]; 77 | 78 | /** 79 | * @return HasMany 80 | */ 81 | public function events() 82 | { 83 | $model = Config::webhookModel(); 84 | 85 | return $this->hasMany( 86 | get_class($model), 87 | $model->getAccountIdentifierName(), 88 | $this->getStripeAccountIdentifierName(), 89 | ); 90 | } 91 | 92 | /** 93 | * @return BelongsTo 94 | */ 95 | public function owner() 96 | { 97 | $model = Config::connectOwner(); 98 | 99 | return $this->belongsTo( 100 | get_class($model), 101 | $this->getStripeOwnerIdentifierName(), 102 | $model->getStripeIdentifierName(), 103 | 'owner', 104 | ); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Connect/Authorizer.php: -------------------------------------------------------------------------------- 1 | client = $client; 52 | $this->state = $state; 53 | } 54 | 55 | /** 56 | * Create a Stripe Connect OAuth link. 57 | * 58 | * @return AuthorizeUrl 59 | * @see https://stripe.com/docs/connect/standard-accounts#integrating-oauth 60 | */ 61 | public function authorizeUrl(?array $options = null) 62 | { 63 | if (!$state = $this->state->get()) { 64 | throw new RuntimeException('State parameter cannot be empty.'); 65 | } 66 | 67 | return new AuthorizeUrl($state, $options); 68 | } 69 | 70 | /** 71 | * Authorize access to an account. 72 | * 73 | * @return StripeObject 74 | * @see https://stripe.com/docs/connect/standard-accounts#token-request 75 | */ 76 | public function authorize(string $code, ?array $options = null) 77 | { 78 | $params = [ 79 | self::CODE => $code, 80 | self::GRANT_TYPE => self::GRANT_TYPE_AUTHORIZATION_CODE, 81 | ]; 82 | 83 | return call_user_func($this->client, OAuth::class, 'token', $params, $options); 84 | } 85 | 86 | public function refresh() 87 | { 88 | // @todo 89 | } 90 | 91 | /** 92 | * Revoke access to an account. 93 | * 94 | * @return StripeObject 95 | * @see https://stripe.com/docs/connect/standard-accounts#revoked-access 96 | */ 97 | public function deauthorize(string $accountId, ?array $options = null) 98 | { 99 | $params = [ 100 | self::STRIPE_USER_ID => $accountId, 101 | ]; 102 | 103 | return call_user_func($this->client, OAuth::class, 'deauthorize', $params, $options); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /database/migrations/2019_07_17_074500_create_stripe_accounts_and_events.php: -------------------------------------------------------------------------------- 1 | string('id', 255)->primary()->collate(LaravelStripe::ID_DATABASE_COLLATION); 35 | $table->timestamps(); 36 | $table->softDeletes(); 37 | $table->json('business_profile')->nullable(); 38 | $table->string('business_type')->nullable(); 39 | $table->json('capabilities')->nullable(); 40 | $table->boolean('charges_enabled')->nullable(); 41 | $table->json('company')->nullable(); 42 | $table->string('country', 3)->nullable(); 43 | $table->timestamp('created')->nullable(); 44 | $table->string('default_currency', 3)->nullable(); 45 | $table->boolean('details_submitted')->nullable(); 46 | $table->string('email')->nullable(); 47 | $table->json('individual')->nullable(); 48 | $table->json('metadata')->nullable(); 49 | $table->unsignedInteger('owner_id')->nullable(); 50 | $table->boolean('payouts_enabled')->nullable(); 51 | $table->string('refresh_token')->nullable(); 52 | $table->json('requirements')->nullable(); 53 | $table->json('settings')->nullable(); 54 | $table->string('token_scope')->nullable(); 55 | $table->json('tos_acceptance')->nullable(); 56 | $table->string('type')->nullable(); 57 | }); 58 | 59 | Schema::create('stripe_events', function (Blueprint $table) { 60 | $table->string('id', 255)->primary()->collate(LaravelStripe::ID_DATABASE_COLLATION); 61 | $table->timestamps(); 62 | $table->string('account_id', 255)->nullable()->collate(LaravelStripe::ID_DATABASE_COLLATION); 63 | $table->date('api_version'); 64 | $table->timestamp('created'); 65 | $table->boolean('livemode'); 66 | $table->unsignedInteger('pending_webhooks'); 67 | $table->string('type'); 68 | $table->json('request')->nullable(); 69 | }); 70 | } 71 | 72 | /** 73 | * Reverse the migration. 74 | * 75 | * @return void 76 | */ 77 | public function down() 78 | { 79 | Schema::dropIfExists('stripe_events'); 80 | Schema::dropIfExists('stripe_accounts'); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Http/Requests/AuthorizeConnect.php: -------------------------------------------------------------------------------- 1 | [ 39 | 'required_without:error', 40 | 'string', 41 | ], 42 | 'state' => [ 43 | 'required', 44 | 'string', 45 | ], 46 | 'scope' => [ 47 | 'sometimes', 48 | Rule::in(AuthorizeUrl::scopes()), 49 | ], 50 | 'error' => [ 51 | 'required_without:code', 52 | 'string', 53 | ], 54 | 'error_description' => [ 55 | 'required_with:error', 56 | 'string', 57 | ], 58 | ]; 59 | } 60 | 61 | /** 62 | * Authorize the request. 63 | * 64 | * @return bool 65 | */ 66 | public function authorize() 67 | { 68 | return $this->owner() instanceof AccountOwnerInterface; 69 | } 70 | 71 | /** 72 | * Get the Stripe account owner for the request. 73 | * 74 | * @return AccountOwnerInterface 75 | */ 76 | public function owner() 77 | { 78 | if ($fn = LaravelStripe::$currentOwnerResolver) { 79 | return call_user_func($fn, $this); 80 | } 81 | 82 | return $this->user(); 83 | } 84 | 85 | /** 86 | * @return array 87 | */ 88 | public function validationData() 89 | { 90 | return $this->query(); 91 | } 92 | 93 | /** 94 | * Handle validation failing. 95 | * 96 | * We do not expect this scenario to occur, because Stripe has defined 97 | * the parameter it sends us. However we handle the scenario just in case. 98 | * 99 | * We do not throw the Laravel validation exception, because by default 100 | * Laravel turns this into a redirect response to send the user back... 101 | * but this does not make sense in our scenario. 102 | * 103 | * @throws HttpException 104 | */ 105 | protected function failedValidation(Validator $validator) 106 | { 107 | throw new HttpException(Response::HTTP_BAD_REQUEST); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Webhooks/Webhook.php: -------------------------------------------------------------------------------- 1 | webhook = $webhook; 57 | $this->model = $model; 58 | $this->config = $config; 59 | } 60 | 61 | /** 62 | * @return string 63 | */ 64 | public function id() 65 | { 66 | return $this->webhook->id; 67 | } 68 | 69 | /** 70 | * Get the type of webhook. 71 | * 72 | * @return string 73 | */ 74 | public function type() 75 | { 76 | return $this->webhook->type; 77 | } 78 | 79 | /** 80 | * Is this a Connect webhook? 81 | * 82 | * Useful for listeners or jobs that run on both account and Connect webhooks. 83 | * 84 | * @return bool 85 | */ 86 | public function connect() 87 | { 88 | return false; 89 | } 90 | 91 | /** 92 | * Is the webhook the specified type? 93 | * 94 | * @return bool 95 | */ 96 | public function is($type) 97 | { 98 | return $this->type() === $type; 99 | } 100 | 101 | /** 102 | * Is the webhook not the specified type. 103 | * 104 | * @param string $type 105 | * @return bool 106 | */ 107 | public function isNot($type) 108 | { 109 | return !$this->is($type); 110 | } 111 | 112 | /** 113 | * Get the configured queue for the webhook. 114 | * 115 | * @return string|null 116 | */ 117 | public function queue() 118 | { 119 | return Arr::get($this->config, 'queue'); 120 | } 121 | 122 | /** 123 | * Get the configured connection for the webhook. 124 | */ 125 | public function connection() 126 | { 127 | return Arr::get($this->config, 'connection'); 128 | } 129 | 130 | /** 131 | * Get the configured job for the webhook. 132 | * 133 | * @return string|null 134 | */ 135 | public function job() 136 | { 137 | return Arr::get($this->config, 'job'); 138 | } 139 | 140 | } 141 | -------------------------------------------------------------------------------- /src/Connector.php: -------------------------------------------------------------------------------- 1 | accounts()->retrieve(); 53 | } 54 | 55 | public function accounts(): Repositories\AccountRepository 56 | { 57 | return new Repositories\AccountRepository( 58 | app(Client::class), 59 | $this->accountId(), 60 | ); 61 | } 62 | 63 | public function balances(): Repositories\BalanceRepository 64 | { 65 | return new Repositories\BalanceRepository( 66 | app(Client::class), 67 | $this->accountId(), 68 | ); 69 | } 70 | 71 | public function charges(): Repositories\ChargeRepository 72 | { 73 | return new Repositories\ChargeRepository( 74 | app(Client::class), 75 | $this->accountId(), 76 | ); 77 | } 78 | 79 | public function events(): Repositories\EventRepository 80 | { 81 | return new Repositories\EventRepository( 82 | app(Client::class), 83 | $this->accountId(), 84 | ); 85 | } 86 | 87 | /** 88 | * Create a payment intents client for the provided account. 89 | */ 90 | public function paymentIntents(): Repositories\PaymentIntentRepository 91 | { 92 | return new Repositories\PaymentIntentRepository( 93 | app(Client::class), 94 | $this->accountId(), 95 | ); 96 | } 97 | 98 | public function refunds(): Repositories\RefundRepository 99 | { 100 | return new Repositories\RefundRepository( 101 | app(Client::class), 102 | $this->accountId(), 103 | ); 104 | } 105 | 106 | /** 107 | * Get the account id to use when creating a repository. 108 | */ 109 | protected function accountId(): ?string 110 | { 111 | return null; 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /src/StripeService.php: -------------------------------------------------------------------------------- 1 | middleware( 42 | "stripe.verify:{$signingSecret}", 43 | ); 44 | } 45 | 46 | /** 47 | * Register an Connect OAuth endpoint. 48 | * 49 | * @return \Illuminate\Routing\Route 50 | */ 51 | public function oauth($uri) 52 | { 53 | return Route::get($uri, '\\' . OAuthController::class); 54 | } 55 | 56 | /** 57 | * Access the main application account. 58 | * 59 | * @return Connector 60 | */ 61 | public function account() 62 | { 63 | return new Connector(); 64 | } 65 | 66 | /** 67 | * Access a connected account. 68 | * 69 | * @param AccountInterface|string $accountId 70 | * @return Connect\Connector 71 | * @throws AccountNotConnectedException 72 | */ 73 | public function connect($accountId) 74 | { 75 | if ($accountId instanceof AccountInterface) { 76 | return new Connect\Connector($accountId); 77 | } 78 | 79 | if ($account = $this->connectAccount($accountId)) { 80 | return new Connect\Connector($account); 81 | } 82 | 83 | throw new AccountNotConnectedException($accountId); 84 | } 85 | 86 | /** 87 | * Get a Stripe Connect account by id. 88 | * 89 | * @return AccountInterface|null 90 | */ 91 | public function connectAccount($accountId) 92 | { 93 | return app('stripe.connect')->find($accountId); 94 | } 95 | 96 | /** 97 | * Create a Stripe Connect OAuth link. 98 | * 99 | * @return AuthorizeUrl 100 | */ 101 | public function authorizeUrl(?array $options = null) 102 | { 103 | return app(Authorizer::class)->authorizeUrl($options); 104 | } 105 | 106 | /** 107 | * Log a Stripe object, sanitising any sensitive data. 108 | * 109 | * @param string $message 110 | */ 111 | public function log($message, $data, array $context = []) 112 | { 113 | app('stripe.log')->encode($message, $data, $context); 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /src/Connect/ConnectedAccount.php: -------------------------------------------------------------------------------- 1 | connect( 29 | $this->getStripeAccountIdentifier(), 30 | ); 31 | } 32 | 33 | /** 34 | * @return string 35 | */ 36 | public function getStripeAccountIdentifier() 37 | { 38 | return $this->{$this->getStripeAccountIdentifierName()}; 39 | } 40 | 41 | /** 42 | * Get the Stripe account ID column name. 43 | * 44 | * If your model does not use an incrementing primary key, we assume 45 | * that the primary key is also the Stripe ID. 46 | * 47 | * If your model does use incrementing primary keys, we default to 48 | * `stripe_account_id` as the column name. 49 | * 50 | * If you use a different name, just implement this method yourself. 51 | * 52 | * @return string 53 | */ 54 | public function getStripeAccountIdentifierName() 55 | { 56 | if (!$this->incrementing) { 57 | return $this->getKeyName(); 58 | } 59 | 60 | return 'stripe_account_id'; 61 | } 62 | 63 | /** 64 | * @return string|null 65 | */ 66 | public function getStripeTokenScope() 67 | { 68 | return $this->{$this->getStripeTokenScopeName()}; 69 | } 70 | 71 | /** 72 | * Get the name for the Stripe token scope. 73 | * 74 | * @return string 75 | */ 76 | public function getStripeTokenScopeName() 77 | { 78 | return $this->hasStripeKey() ? 'token_scope' : 'stripe_token_scope'; 79 | } 80 | 81 | /** 82 | * Get the Stripe refresh token. 83 | * 84 | * @return string|null 85 | */ 86 | public function getStripeRefreshToken() 87 | { 88 | return $this->{$this->getStripeRefreshTokenName()}; 89 | } 90 | 91 | /** 92 | * Get the Stripe refresh token column name. 93 | * 94 | * @return string 95 | */ 96 | public function getStripeRefreshTokenName() 97 | { 98 | return $this->hasStripeKey() ? 'refresh_token' : 'stripe_refresh_token'; 99 | } 100 | 101 | /** 102 | * Get the user id that the account is associated to. 103 | * 104 | * @return mixed|null 105 | */ 106 | public function getStripeOwnerIdentifier() 107 | { 108 | return $this->{$this->getStripeOwnerIdentifierName()}; 109 | } 110 | 111 | /** 112 | * Get the user id column name. 113 | * 114 | * If this method returns null, the user will not be stored 115 | * when an access token is fetched. 116 | * 117 | * @return string|null 118 | */ 119 | public function getStripeOwnerIdentifierName() 120 | { 121 | return 'owner_id'; 122 | } 123 | 124 | /** 125 | * Is the model using the Stripe account identifier as its key? 126 | * 127 | * @return bool 128 | */ 129 | protected function hasStripeKey() 130 | { 131 | return $this->getKeyName() === $this->getStripeAccountIdentifierName(); 132 | } 133 | 134 | } 135 | -------------------------------------------------------------------------------- /src/Http/Controllers/OAuthController.php: -------------------------------------------------------------------------------- 1 | log = $log; 43 | } 44 | 45 | /** 46 | * Handle the Stripe Connect authorize endpoint. 47 | * 48 | * @return Response 49 | */ 50 | public function __invoke(AuthorizeConnect $request, StateProviderInterface $state) 51 | { 52 | $data = collect($request->query())->only([ 53 | 'code', 54 | 'scope', 55 | 'error', 56 | 'error_description', 57 | ]); 58 | 59 | $owner = $request->owner(); 60 | 61 | $this->log->log('Received OAuth redirect.', $data->all()); 62 | 63 | /** Check the state parameter and return an error if it is not as expected. */ 64 | if (true !== $state->check($request->query('state'))) { 65 | return $this->error(Response::HTTP_FORBIDDEN, [ 66 | 'error' => OAuthError::LARAVEL_STRIPE_FORBIDDEN, 67 | 'error_description' => 'Invalid authorization token.', 68 | ], $owner); 69 | } 70 | 71 | /** If Stripe has told there is an error, return an error response. */ 72 | if ($data->has('error')) { 73 | return $this->error( 74 | Response::HTTP_UNPROCESSABLE_ENTITY, 75 | $data, 76 | $owner, 77 | ); 78 | } 79 | 80 | /** Otherwise return our success view. */ 81 | return $this->success($data, $owner); 82 | } 83 | 84 | /** 85 | * Handle success. 86 | * 87 | * @return Response 88 | */ 89 | protected function success($data, $user) 90 | { 91 | event($success = new OAuthSuccess( 92 | $data['code'], 93 | $data['scope'] ?? null, 94 | $user, 95 | Config::connectSuccessView(), 96 | )); 97 | 98 | return response()->view($success->view, $success->all()); 99 | } 100 | 101 | /** 102 | * Handle an error. 103 | * 104 | * @param int $status 105 | * @return Response 106 | */ 107 | protected function error($status, $data, $user) 108 | { 109 | event($error = new OAuthError( 110 | $data['error'], 111 | $data['error_description'], 112 | $user, 113 | Config::connectErrorView(), 114 | )); 115 | 116 | return response()->view( 117 | $error->view, 118 | $error->all(), 119 | $status, 120 | ); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/LaravelStripe.php: -------------------------------------------------------------------------------- 1 | paymentIntents() 29 | ->create('gbp', 1500); 30 | 31 | // For a Stripe Connect account model: 32 | $account->stripe()->paymentIntents()->create('gbp', 999); 33 | ``` 34 | 35 | ### What About Cashier? 36 | 37 | This package is meant to be used *in addition* to [Laravel Cashier](https://laravel.com/docs/billing), 38 | not instead of it. 39 | 40 | Our primary use-case is Stripe Connect. We needed a package that provided really easy access to data from 41 | connected Stripe accounts. We wanted to make interacting with the entire Stripe API fluent, 42 | easily testable and highly debuggable. 43 | 44 | In contrast, Cashier does not provide full Stripe API coverage, and provides 45 | [no support for Stripe Connect.](https://github.com/laravel/cashier/pull/519) 46 | So if you need to do more than just Cashier's billing functionality, install this package as well. 47 | 48 | ## Installation 49 | 50 | Installation is via Composer. Refer to the [Installation Guide](./docs/installation.md) for 51 | instructions. 52 | 53 | ## Documentation 54 | 55 | 1. [Installation](./docs/installation.md) 56 | 2. [Accessing the Stripe API](./docs/repositories.md) 57 | 3. [Receiving Webhooks](./docs/webhooks.md) 58 | 4. [Stripe Connect](./docs/connect.md) 59 | 5. [Artisan Commands](./docs/console.md) 60 | 6. [Testing](./docs/testing.md) 61 | 62 | ## Version Compatibility 63 | 64 | The following table shows which version to install. We have provided the Stripe API version that we 65 | developed against as guide. You may find the package works with older versions of the API. 66 | 67 | | Laravel | Stripe PHP | Stripe API | Laravel-Stripe | Cashier | 68 | |:--------|:-----------|:---------------|:---------------|:----------------------------| 69 | | `12.x` | `^16.2` | `>=2020-03-02` | `0.8.x` | `^15.6` | 70 | | `11.x` | `^16.2` | `>=2020-03-02` | `0.8.x` | `^15.6` | 71 | | `10.x` | `^7.52` | `>=2020-03-02` | `0.7.x` | `^14.8` | 72 | | `9.x` | `^7.52` | `>=2020-03-02` | `0.6.x` | `^12.3` | 73 | | `8.x` | `^7.52` | `>=2020-03-02` | `0.5.x\|0.6.x` | `^12.3` | 74 | | `7.x` | `^7.0` | `>=2020-03-02` | `0.4.x` | `^12.0` | 75 | | `6.x` | `^6.40` | `>=2019-05-16` | `0.2.x` | `^9.0\|^10.0\|^11.0\|^12.0` | 76 | 77 | ## Contributing 78 | 79 | We have only implemented the repositories for the Stripe resources we are using in our application. 80 | Repositories are very easy to implement - for example, the 81 | [payment intent repository](./src/Repositories/PaymentIntentRepository.php) - 82 | because they are predominantly composed of traits. Then they just need to be added to 83 | [the connector class](./src/Connector.php). 84 | 85 | If you find this package is missing a resource you need in your application, an ideal way to contribute 86 | is to submit a pull request to add the missing repository. 87 | -------------------------------------------------------------------------------- /tests/lib/Integration/Webhooks/ProcessTest.php: -------------------------------------------------------------------------------- 1 | create([ 40 | 'updated_at' => Carbon::now()->subMinute(), 41 | ]); 42 | 43 | $payload = [ 44 | 'id' => $model->getKey(), 45 | 'type' => 'charge.failed', 46 | ]; 47 | 48 | dispatch(new ProcessWebhook($model, $payload)); 49 | 50 | $expected = [ 51 | 'stripe.webhooks', 52 | 'stripe.webhooks:charge', 53 | 'stripe.webhooks:charge.failed', 54 | ]; 55 | 56 | foreach ($expected as $name) { 57 | Event::assertDispatched( 58 | $name, 59 | function ($ev, Webhook $webhook) use ($name, $model, $payload) { 60 | $this->assertNotInstanceOf(ConnectWebhook::class, $webhook, 'not connect'); 61 | $this->assertEquals(\Stripe\Event::constructFrom($payload), $webhook->webhook, "{$name}: webhook"); 62 | $this->assertTrue($model->is($webhook->model), "{$name}: model"); 63 | return true; 64 | }, 65 | ); 66 | } 67 | 68 | /** Ensure the model had its timestamp updated. */ 69 | $this->assertDatabaseHas('stripe_events', [ 70 | $model->getKeyName() => $model->getKey(), 71 | 'updated_at' => Carbon::now()->toDateTimeString(), 72 | ]); 73 | } 74 | 75 | public function testConnect() 76 | { 77 | $model = factory(StripeEvent::class)->states('connect')->create(); 78 | 79 | $payload = [ 80 | 'id' => $model->getKey(), 81 | 'account' => $model->account_id, 82 | 'type' => 'payment_intent.succeeded', 83 | ]; 84 | 85 | $job = new ProcessWebhook($model, $payload); 86 | $job->onConnection('sync')->onQueue('my_queue'); 87 | 88 | dispatch($job); 89 | 90 | $expected = [ 91 | 'stripe.connect.webhooks', 92 | 'stripe.connect.webhooks:payment_intent', 93 | 'stripe.connect.webhooks:payment_intent.succeeded', 94 | ]; 95 | 96 | foreach ($expected as $name) { 97 | Event::assertDispatched( 98 | $name, 99 | function ($ev, ConnectWebhook $webhook) use ($name, $model, $payload) { 100 | $this->assertEquals(\Stripe\Event::constructFrom($payload), $webhook->webhook, "{$name}: webhook"); 101 | $this->assertTrue($model->account->is($webhook->account), "{$name}: account"); 102 | $this->assertTrue($model->is($webhook->model), "{$name}: model"); 103 | return true; 104 | }, 105 | ); 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Log/Logger.php: -------------------------------------------------------------------------------- 1 | log = $log; 53 | $this->level = $level ?: 'debug'; 54 | $this->exclude = $exclude; 55 | } 56 | 57 | /** 58 | * Log a message at the configured level. 59 | * 60 | * @param string $message 61 | * @return void 62 | */ 63 | public function log($message, array $context = []) 64 | { 65 | $this->log->log($this->level, 'Stripe: ' . $message, $context); 66 | } 67 | 68 | /** 69 | * Encode data into an error message. 70 | * 71 | * @param string $message 72 | * @return void 73 | */ 74 | public function encode($message, $data, array $context = []) 75 | { 76 | $message .= ':' . PHP_EOL . $this->toJson($data); 77 | 78 | $this->log($message, $context); 79 | } 80 | 81 | /** 82 | * Encode a Stripe object for a log message. 83 | * 84 | * @return string 85 | */ 86 | private function toJson($data) 87 | { 88 | if ($data instanceof JsonSerializable) { 89 | $data = $data->jsonSerialize(); 90 | } 91 | 92 | $data = $this->serialize((array) $data); 93 | 94 | return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 95 | } 96 | 97 | /** 98 | * @return array 99 | */ 100 | private function serialize(array $data) 101 | { 102 | $this->sanitise($data); 103 | 104 | return collect($data)->map(function ($value) { 105 | return is_array($value) ? $this->serialize($value) : $value; 106 | })->all(); 107 | } 108 | 109 | private function sanitise(array &$data) 110 | { 111 | $name = $data['object'] ?? null; 112 | 113 | /** Stripe webhooks contain an object key that is not a string. */ 114 | if (!is_string($name)) { 115 | return; 116 | } 117 | 118 | foreach ($this->exclude($data['object']) as $path) { 119 | if (!$value = Arr::get($data, $path)) { 120 | continue; 121 | } 122 | 123 | if (is_string($value)) { 124 | Arr::set($data, $path, '***'); 125 | } else { 126 | Arr::forget($data, $path); 127 | } 128 | } 129 | } 130 | 131 | /** 132 | * Get the paths to exclude from logging. 133 | * 134 | * @return array 135 | */ 136 | private function exclude($name) 137 | { 138 | if (isset($this->exclude[$name])) { 139 | return (array) $this->exclude[$name]; 140 | } 141 | 142 | return $this->exclude[$name] = []; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /tests/lib/Integration/Webhooks/ListenersTest.php: -------------------------------------------------------------------------------- 1 | 'evt_00000000', 46 | 'type' => 'payment_intent.succeeded', 47 | ]); 48 | 49 | event('stripe.webhooks', $webhook = new Webhook( 50 | $event, 51 | factory(StripeEvent::class)->create(), 52 | ['queue' => 'my_queue', 'connection' => 'my_connection', 'job' => TestWebhookJob::class], 53 | )); 54 | 55 | Queue::assertPushedOn('my_queue', TestWebhookJob::class, function ($job) use ($webhook) { 56 | $this->assertSame($webhook, $job->webhook); 57 | $this->assertSame('my_queue', $job->queue, 'queue'); 58 | $this->assertSame('my_connection', $job->connection, 'connection'); 59 | return true; 60 | }); 61 | } 62 | 63 | /** 64 | * If there are any configured webhook jobs, we expect them to be dispatched 65 | * when the `stripe.connect.webhooks` event is fired. They should be dispatched on the 66 | * same queue and connection as the webhook itself. 67 | */ 68 | public function testConnect() 69 | { 70 | $event = Event::constructFrom([ 71 | 'id' => 'evt_00000000', 72 | 'type' => 'payment_intent.succeeded', 73 | 'account' => 'acct_0000000000', 74 | ]); 75 | 76 | $model = factory(StripeEvent::class)->states('connect')->create(); 77 | 78 | event('stripe.connect.webhooks', $webhook = new ConnectWebhook( 79 | $event, 80 | $model->account, 81 | $model, 82 | ['job' => TestWebhookJob::class], 83 | )); 84 | 85 | Queue::assertPushed(TestWebhookJob::class, function ($job) use ($webhook) { 86 | $this->assertSame($webhook, $job->webhook); 87 | $this->assertNull($job->queue, 'queue'); 88 | $this->assertNull($job->connection, 'connection'); 89 | return true; 90 | }); 91 | } 92 | 93 | /** 94 | * Test it does not dispatch a job if the webhook is not for the specified event type. 95 | */ 96 | public function testDoesNotDispatch() 97 | { 98 | event('stripe.webhooks', $webhook = new Webhook( 99 | Event::constructFrom(['id' => 'evt_00000000', 'type' => 'charge.refunded']), 100 | factory(StripeEvent::class)->create(), 101 | ['job' => null], 102 | )); 103 | 104 | Queue::assertNotPushed(TestWebhookJob::class); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/lib/Unit/Connect/AuthorizeUrlTest.php: -------------------------------------------------------------------------------- 1 | url = new AuthorizeUrl('state_secret'); 39 | } 40 | 41 | protected function tearDown(): void 42 | { 43 | parent::tearDown(); 44 | Stripe::setClientId(null); 45 | } 46 | 47 | public static function valueProvider(): array 48 | { 49 | return [ 50 | 'read_only' => [ 51 | ['scope' => 'read_only'], 52 | 'readOnly', 53 | ], 54 | 'read_write' => [ 55 | ['scope' => 'read_write'], 56 | 'readWrite', 57 | ], 58 | 'redirect_uri' => [ 59 | ['redirect_uri' => 'https://example.com'], 60 | 'redirectUri', 61 | 'https://example.com', 62 | ], 63 | 'login' => [ 64 | ['stripe_landing' => 'login'], 65 | 'login', 66 | ], 67 | 'register' => [ 68 | ['stripe_landing' => 'register'], 69 | 'register', 70 | ], 71 | 'always_prompt' => [ 72 | ['always_prompt' => 'true'], 73 | 'alwaysPrompt', 74 | ], 75 | 'user' => [ 76 | ['stripe_user' => ['email' => 'bob@example.com']], 77 | 'user', 78 | ['email' => 'bob@example.com'], 79 | ], 80 | 'stripe_user' => [ 81 | ['stripe_user' => ['email' => 'bob@example.com']], 82 | 'stripeUser', 83 | ['email' => 'bob@example.com', 'foo' => null], 84 | ], 85 | ]; 86 | } 87 | 88 | #[DataProvider('valueProvider')] 89 | public function testStandard(array $expected, string $method, mixed $value = null): void 90 | { 91 | $args = !is_null($value) ? [$value] : []; 92 | $result = call_user_func_array([$this->url, $method], $args); 93 | 94 | $this->assertSame($this->url, $result, "{$method} is fluent"); 95 | $this->assertUrl('https://connect.stripe.com/oauth/authorize', $expected, "{$method}"); 96 | } 97 | 98 | #[DataProvider('valueProvider')] 99 | public function testExpress(array $expected, string $method, mixed $value = null): void 100 | { 101 | $this->assertSame($this->url, $this->url->express(), 'express is fluent'); 102 | 103 | $args = !is_null($value) ? [$value] : []; 104 | $result = call_user_func_array([$this->url, $method], $args); 105 | 106 | $this->assertSame($this->url, $result, "{$method} is fluent"); 107 | $this->assertUrl('https://connect.stripe.com/express/oauth/authorize', $expected, "{$method}"); 108 | } 109 | 110 | private function assertUrl(string $uri, array $params, string $message = ''): void 111 | { 112 | $params = array_replace([ 113 | 'state' => 'state_secret', 114 | 'response_type' => 'code', 115 | ], $params); 116 | 117 | ksort($params); 118 | 119 | $params['client_id'] = 'my_client_id'; 120 | 121 | $expected = $uri . '?' . Util::encodeParameters($params); 122 | 123 | $this->assertSame($expected, (string) $this->url, $message); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Console/Commands/StripeQuery.php: -------------------------------------------------------------------------------- 1 | argument('resource'))); 59 | $id = $this->argument('id'); 60 | $account = $this->option('account'); 61 | 62 | try { 63 | /** @var Connector $connector */ 64 | $connector = $account ? $stripe->connect($account) : $stripe->account(); 65 | 66 | if (('balances' === $resource) && $id) { 67 | throw new UnexpectedValueException('The id parameter is not supported for the balances resource.'); 68 | } 69 | 70 | /** @var AbstractRepository $repository */ 71 | $repository = call_user_func($connector, $resource); 72 | 73 | if ($expand = $this->option('expand')) { 74 | $repository->expand(...$expand); 75 | } 76 | 77 | /** Get the result */ 78 | $result = $id ? 79 | $this->retrieve($repository, $resource, $id) : 80 | $this->query($repository, $resource); 81 | } catch (UnexpectedValueException $ex) { 82 | $this->error($ex->getMessage()); 83 | return 1; 84 | } catch (ApiErrorException $ex) { 85 | $this->error('Stripe Error: ' . $ex->getMessage()); 86 | return 2; 87 | } 88 | 89 | $this->line(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 90 | 91 | return 0; 92 | } 93 | 94 | /** 95 | * @param string $resource 96 | * @param string $id 97 | * @throws ApiErrorException 98 | */ 99 | private function retrieve(AbstractRepository $repository, $resource, $id): JsonSerializable 100 | { 101 | if (!method_exists($repository, 'retrieve')) { 102 | throw new UnexpectedValueException("Retrieving resource '{$resource}' is not supported."); 103 | } 104 | 105 | $this->info(sprintf('Retrieving %s %s', Str::singular($resource), $id)); 106 | 107 | return $repository->retrieve($id); 108 | } 109 | 110 | /** 111 | * @throws ApiErrorException 112 | * @todo add support for pagination. 113 | */ 114 | private function query(AbstractRepository $repository, $resource): JsonSerializable 115 | { 116 | if ('balances' === $resource) { 117 | return $repository->retrieve(); 118 | } 119 | 120 | if (!method_exists($repository, 'all')) { 121 | throw new UnexpectedValueException("Querying resource '{$resource}' is not supported."); 122 | } 123 | 124 | $this->info("Querying {$resource}"); 125 | 126 | return $repository->all(); 127 | } 128 | 129 | } 130 | -------------------------------------------------------------------------------- /tests/lib/Integration/TestCase.php: -------------------------------------------------------------------------------- 1 | app['migrator']->path(__DIR__ . '/../../database/migrations'); 42 | $this->app->make(ModelFactory::class)->load(__DIR__ . '/../../database/factories'); 43 | 44 | if (method_exists($this, 'withoutMockingConsoleOutput')) { 45 | $this->withoutMockingConsoleOutput(); 46 | } 47 | 48 | $this->app['view']->addNamespace('test', __DIR__ . '/../../resources/views'); 49 | 50 | $this->artisan('migrate', ['--database' => 'testbench']); 51 | } 52 | 53 | protected function tearDown(): void 54 | { 55 | parent::tearDown(); 56 | Carbon::setTestNow(); 57 | } 58 | 59 | /** 60 | * Provider for all Stripe classes that are implemented via repositories. 61 | * 62 | * Balances are omitted because they do not have an id. 63 | */ 64 | public static function classProvider(): array 65 | { 66 | return [ 67 | 'accounts' => [\Stripe\Account::class, 'accounts'], 68 | 'charges' => [\Stripe\Charge::class, 'charges'], 69 | 'events' => [\Stripe\Event::class, 'events'], 70 | 'payment_intents' => [\Stripe\PaymentIntent::class, 'payment_intents'], 71 | ]; 72 | } 73 | 74 | /** 75 | * Get package providers. 76 | * 77 | * To ensure this package works with Cashier, we also include 78 | * Cashier. 79 | * 80 | * @param Application $app 81 | * @return array 82 | */ 83 | protected function getPackageProviders($app) 84 | { 85 | return [ 86 | CashierServiceProvider::class, 87 | ServiceProvider::class, 88 | ]; 89 | } 90 | 91 | /** 92 | * Get facade aliases. 93 | * 94 | * @param Application $app 95 | * @return array 96 | */ 97 | protected function getPackageAliases($app) 98 | { 99 | return [ 100 | 'Stripe' => Stripe::class, 101 | ]; 102 | } 103 | 104 | /** 105 | * Setup the test environment. 106 | * 107 | * @param Application $app 108 | * @return void 109 | */ 110 | protected function getEnvironmentSetUp($app) 111 | { 112 | /** Include our default config. */ 113 | $app['config']->set('stripe', require __DIR__ . '/../../../config/stripe.php'); 114 | 115 | /** Override views to use our test namespace */ 116 | $app['config']->set('stripe.connect.views', [ 117 | 'success' => 'test::oauth.success', 118 | 'error' => 'test::oauth.error', 119 | ]); 120 | 121 | /** Setup a test database. */ 122 | $app['config']->set('database.default', 'testbench'); 123 | $app['config']->set('database.connections.testbench', [ 124 | 'driver' => 'sqlite', 125 | 'database' => ':memory:', 126 | 'prefix' => '', 127 | ]); 128 | } 129 | 130 | /** 131 | * Load a stub. 132 | * 133 | * @param string $name 134 | * @return array 135 | */ 136 | protected function stub($name) 137 | { 138 | return json_decode( 139 | file_get_contents(__DIR__ . '/../../stubs/' . $name . '.json'), 140 | true, 141 | ); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /tests/stubs/webhook.json: -------------------------------------------------------------------------------- 1 | { 2 | "created": 1326853478, 3 | "livemode": false, 4 | "id": "evt_00000000000000", 5 | "type": "charge.failed", 6 | "object": "event", 7 | "request": null, 8 | "pending_webhooks": 1, 9 | "api_version": "2017-04-06", 10 | "account": "acct_00000000000000", 11 | "user_id": "acct_00000000000000", 12 | "data": { 13 | "object": { 14 | "id": "ch_00000000000000", 15 | "object": "charge", 16 | "amount": 1050, 17 | "amount_refunded": 0, 18 | "application": null, 19 | "application_fee": null, 20 | "application_fee_amount": null, 21 | "balance_transaction": "txn_00000000000000", 22 | "billing_details": { 23 | "address": { 24 | "city": null, 25 | "country": null, 26 | "line1": null, 27 | "line2": null, 28 | "postal_code": null, 29 | "state": null 30 | }, 31 | "email": null, 32 | "name": null, 33 | "phone": null 34 | }, 35 | "captured": true, 36 | "created": 1407230178, 37 | "currency": "gbp", 38 | "customer": null, 39 | "description": "Foobar", 40 | "destination": null, 41 | "dispute": null, 42 | "failure_code": null, 43 | "failure_message": null, 44 | "fraud_details": [], 45 | "invoice": null, 46 | "livemode": false, 47 | "metadata": [], 48 | "on_behalf_of": null, 49 | "order": null, 50 | "outcome": { 51 | "network_status": null, 52 | "reason": null, 53 | "risk_level": "not_assessed", 54 | "seller_message": "Payment complete.", 55 | "type": "authorized" 56 | }, 57 | "paid": false, 58 | "payment_intent": null, 59 | "payment_method": "card_00000000000000", 60 | "payment_method_details": { 61 | "card": { 62 | "brand": "visa", 63 | "checks": { 64 | "address_line1_check": null, 65 | "address_postal_code_check": null, 66 | "cvc_check": "pass" 67 | }, 68 | "country": "US", 69 | "exp_month": 8, 70 | "exp_year": 2015, 71 | "fingerprint": "hdKs5tUDfiYHfThA", 72 | "funding": "credit", 73 | "last4": "4242", 74 | "three_d_secure": null, 75 | "wallet": null 76 | }, 77 | "type": "card" 78 | }, 79 | "receipt_email": null, 80 | "receipt_number": null, 81 | "receipt_url": "https://pay.stripe.com/receipts/acct_238KrmuR0uhRnDxnilrv/ch_4X8JtIYiSwHJ0o/rcpt_EHtcPdhql5WJmhXqNUD7pGTEHCA391h", 82 | "refunded": false, 83 | "refunds": { 84 | "object": "list", 85 | "data": [], 86 | "has_more": false, 87 | "total_count": 0, 88 | "url": "/v1/charges/ch_4X8JtIYiSwHJ0o/refunds" 89 | }, 90 | "review": null, 91 | "shipping": null, 92 | "source": { 93 | "id": "card_00000000000000", 94 | "object": "card", 95 | "address_city": null, 96 | "address_country": null, 97 | "address_line1": null, 98 | "address_line1_check": null, 99 | "address_line2": null, 100 | "address_state": null, 101 | "address_zip": null, 102 | "address_zip_check": null, 103 | "brand": "Visa", 104 | "country": "US", 105 | "customer": null, 106 | "cvc_check": "pass", 107 | "dynamic_last4": null, 108 | "exp_month": 8, 109 | "exp_year": 2015, 110 | "fingerprint": "hdKs5tUDfiYHfThA", 111 | "funding": "credit", 112 | "last4": "4242", 113 | "metadata": [], 114 | "name": null, 115 | "tokenization_method": null 116 | }, 117 | "source_transfer": null, 118 | "statement_descriptor": null, 119 | "status": "failed", 120 | "transfer_data": null, 121 | "transfer_group": null 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Connect/Adapter.php: -------------------------------------------------------------------------------- 1 | model = $model; 47 | } 48 | 49 | public function find($accountId) 50 | { 51 | return $this->query($accountId)->first(); 52 | } 53 | 54 | public function store($accountId, $refreshToken, $scope, AccountOwnerInterface $owner) 55 | { 56 | $account = $this->findWithTrashed($accountId) ?: $this->newInstance($accountId); 57 | $account->{$this->model->getStripeRefreshTokenName()} = $refreshToken; 58 | $account->{$this->model->getStripeTokenScopeName()} = $scope; 59 | $account->{$this->model->getStripeOwnerIdentifierName()} = $owner->getStripeIdentifier(); 60 | 61 | if ($account->exists && $this->softDeletes()) { 62 | $account->restore(); 63 | } else { 64 | $account->save(); 65 | } 66 | 67 | return $account; 68 | } 69 | 70 | public function update(AccountInterface $account, Account $resource) 71 | { 72 | if (!$account instanceof $this->model) { 73 | throw new UnexpectedValueException('Unexpected Stripe account model.'); 74 | } 75 | 76 | if ($account->getStripeAccountIdentifier() !== $resource->id) { 77 | throw new UnexpectedValueException('Unexpected Stripe account resource.'); 78 | } 79 | 80 | $account->update($resource->jsonSerialize()); 81 | } 82 | 83 | public function remove(AccountInterface $account) 84 | { 85 | if (!$account instanceof $this->model) { 86 | throw new UnexpectedValueException('Unexpected Stripe account model.'); 87 | } 88 | 89 | $account->{$this->model->getStripeRefreshTokenName()} = null; 90 | $account->{$this->model->getStripeTokenScopeName()} = null; 91 | $account->save(); 92 | 93 | if ($this->softDeletes()) { 94 | $account->delete(); 95 | } 96 | } 97 | 98 | /** 99 | * @return Builder 100 | */ 101 | protected function query($accountId) 102 | { 103 | return $this->model->newQuery()->where( 104 | $this->model->getStripeAccountIdentifierName(), 105 | $accountId, 106 | ); 107 | } 108 | 109 | /** 110 | * @return Model|null 111 | */ 112 | protected function findWithTrashed($accountId) 113 | { 114 | $query = $this->query($accountId); 115 | 116 | if ($this->softDeletes()) { 117 | $query->withTrashed(); 118 | } 119 | 120 | return $query->first(); 121 | } 122 | 123 | /** 124 | * Make a new instance of the account model. 125 | * 126 | * @return Model 127 | */ 128 | protected function newInstance($accountId) 129 | { 130 | $account = $this->model->newInstance(); 131 | $account->{$this->model->getStripeAccountIdentifierName()} = $accountId; 132 | 133 | return $account; 134 | } 135 | 136 | /** 137 | * Does the model soft-delete? 138 | * 139 | * @return bool 140 | */ 141 | protected function softDeletes() 142 | { 143 | return method_exists($this->model, 'forceDelete'); 144 | } 145 | 146 | } 147 | -------------------------------------------------------------------------------- /src/Repositories/AbstractRepository.php: -------------------------------------------------------------------------------- 1 | client = $client; 62 | $this->params = []; 63 | $this->options = []; 64 | 65 | if ($accountId) { 66 | $this->option(self::OPT_STRIPE_ACCOUNT, $accountId); 67 | } 68 | } 69 | 70 | /** 71 | * Get the account id. 72 | */ 73 | public function accountId(): ?string 74 | { 75 | return $this->options[self::OPT_STRIPE_ACCOUNT] ?? null; 76 | } 77 | 78 | /** 79 | * Make the next request idempotent. 80 | * 81 | * @param string $value 82 | * @return $this 83 | */ 84 | public function idempotent($value): self 85 | { 86 | if (!is_string($value) || empty($value)) { 87 | throw new InvalidArgumentException('Expecting a non-empty string.'); 88 | } 89 | 90 | $this->option(self::OPT_IDEMPOTENCY_KEY, $value); 91 | 92 | return $this; 93 | } 94 | 95 | /** 96 | * Set a parameter. 97 | * 98 | * @return $this 99 | */ 100 | public function param(string $key, $value): self 101 | { 102 | $this->params[$key] = $value; 103 | 104 | return $this; 105 | } 106 | 107 | /** 108 | * Set many parameters. 109 | * 110 | * @return $this 111 | */ 112 | public function params(iterable $values): self 113 | { 114 | foreach ($values as $key => $value) { 115 | $this->param($key, $value); 116 | } 117 | 118 | return $this; 119 | } 120 | 121 | /** 122 | * Set an option. 123 | * 124 | * @return $this 125 | */ 126 | public function option(string $key, $value): self 127 | { 128 | $this->options[$key] = $value; 129 | 130 | return $this; 131 | } 132 | 133 | /** 134 | * Set many options. 135 | * 136 | * @return $this 137 | */ 138 | public function options(iterable $values): self 139 | { 140 | foreach ($values as $key => $value) { 141 | $this->option($key, $value); 142 | } 143 | 144 | return $this; 145 | } 146 | 147 | /** 148 | * Set keys to expand. 149 | * 150 | * @return $this 151 | */ 152 | public function expand(string ...$keys): self 153 | { 154 | if (!empty($keys)) { 155 | $this->param(self::PARAM_EXPAND, $keys); 156 | } 157 | 158 | return $this; 159 | } 160 | 161 | /** 162 | * Call the static Stripe method with the provided arguments. 163 | * 164 | * We call everything via this method so that: 165 | * 166 | * - The static call can be stubbed out in tests. 167 | * - Events are dispatched. 168 | */ 169 | protected function send(string $method, ...$args) 170 | { 171 | $result = call_user_func( 172 | $this->client, 173 | $this->fqn(), 174 | $method, 175 | ...$args, 176 | ); 177 | 178 | $this->reset(); 179 | 180 | return $result; 181 | } 182 | 183 | protected function reset(): void 184 | { 185 | $this->params = []; 186 | $this->options = collect($this->options)->only(self::OPT_STRIPE_ACCOUNT)->all(); 187 | } 188 | 189 | } 190 | --------------------------------------------------------------------------------