gnikyt / laravel-shopify

A full-featured Laravel package for aiding in Shopify App development
MIT License
1.24k stars 375 forks source link

Free access for partners and Shopify employees #63

Closed tobiasdalhof closed 6 years ago

tobiasdalhof commented 6 years ago

What is the best way to enable free access for partners and Shopify employees?

My first idea:

GET /admin/shop.json

Partners

if ($shop->plan_name === 'affiliate') {
    return $next($request);
}

Shopify employees

// @see https://help.shopify.com/en/api/app-store/being-successful-in-the-app-store/offering-employee-discounts
if ($shop->plan_name === 'staff_business') {
   return $next($request);
}

So i would need a way to skip or extend the billable middleware and call the shop.json endpoint (and store the plan_name in session?) before the billable middleware.

Should i add my own billable middleware to App/Http/Kernel.php?

Any ideas?

tobiasdalhof commented 6 years ago
<?php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use OhMyBrew\ShopifyApp\Facades\ShopifyApp;
use OhMyBrew\ShopifyApp\Models\Charge;

class Billable
{
    /**
     * Checks if a shop has paid for access.
     *
     * @param \Illuminate\Http\Request $request
     * @param \Closure                 $next
     *
     * @return mixed
     */
    public function handle(Request $request, Closure $next)
    {
        $shopifyPlanName = $request->session()->get('shopify.shop.plan_name');

        if (empty($shopifyPlanName)) {
            $api = ShopifyApp::shop()->api();
            $shopifyPlanName = $api->rest('GET', '/admin/shop.json')->body->shop->plan_name;
            $request->session()->put('shopify.shop.plan_name', $shopifyPlanName);
        }

        if (
            $shopifyPlanName === 'affiliate' ||
            $shopifyPlanName === 'staff_business'
        ) {
            // Free access for partners and Shopify employees
            return $next($request);
        }

        if (config('shopify-app.billing_enabled') === true) {
            // Grab the shop and last recurring or one-time charge
            $shop = ShopifyApp::shop();
            $lastCharge = $shop->charges()
                ->whereIn('type', [Charge::CHARGE_RECURRING, Charge::CHARGE_ONETIME])
                ->orderBy('created_at', 'desc')
                ->first();
            if (
                !$shop->isGrandfathered() &&
                (is_null($lastCharge) || $lastCharge->isDeclined() || $lastCharge->isCancelled())
            ) {
                // They're not grandfathered in, and there is no charge or charge was declined... redirect to billing
                return redirect()->route('billing');
            }
        }
        // Move on, everything's fine
        return $next($request);
    }
}
gnikyt commented 6 years ago

@tobiasdalhof Wonder if theres a better way... doing an API call on the middleware seems harsh. Hmm... what if we did the check in the method which creates the shop... check the plan there and if they're staff or affiliate, set grandfathered to true on create. I can possibily look into this as a feature addition, what do you think?

tobiasdalhof commented 6 years ago

Good morning @ohmybrew

I agree with you, API calls on the middleware are harsh. I reopened the issue.

In my opinion it would make perfect sense to add this feature to this package since Shopify recommends app developers to give partners and Shopify employees free app access.

ShopifyApp@shop() is the method, right? https://github.com/ohmybrew/laravel-shopify/blob/e55c3e80a17e958f296c823644d2573ee742c3da/src/ShopifyApp/ShopifyApp.php#L41

Should we introduce a new config key for free shopify plan names?

Something like this?

    /*
    |--------------------------------------------------------------------------
    | Billing Free Access
    |--------------------------------------------------------------------------
    |
    | This option is for enabling free access for
    | specific Shopify plan names.
    |
    | The Billable middleware will grandfather all
    | shops with matching plan name.
    |
    | Partners === 'affiliate'
    | Shopify employees === 'staff_business'
    |
    */
    'billing_free_access' => [
        /*
        env('SHOPIFY_BILLING_FREE_ACCESS_PARTNERS', 'affiliate'),
        env('SHOPIFY_BILLING_FREE_ACCESS_EMPLOYEES', 'staff_business'),
        */
    ],
tobiasdalhof commented 6 years ago

Here is a list of possbile plan names which i found @ https://ecommerce.shopify.com/c/shopify-apis-and-technology/t/enumeration-of-plan_name-from-get-admin-shop-json-352847

"affiliate",
"staff",
"professional",
"custom",
"shopify_plus",
"unlimited",
"basic",
"cancelled",
"staff_business",
"trial",
"dormant",
"frozen",
"singtel_unlimited",
"npo_lite",
"singtel_professional",
"singtel_trial",
"npo_full",
"business",
"singtel_basic",
"uafrica_professional",
"sales_training",
"singtel_starter",
"uafrica_basic",
"fraudulent",
"enterprise",
"starter",
"comped",
"shopify_alumni"

Im not quite sure what the difference between staff and staff_business is? Maybe staff is showing up in the app review process and staff_business are regular stores by Shopify employees?

Edit: staff_business should be good according to https://help.shopify.com/en/api/app-store/being-successful-in-the-app-store/offering-employee-discounts

brianakidd commented 6 years ago

Could this be simplified by providing a method where the developer simply returns true or false as to whether the shop should be billed? There are cases where a developer may want to give free access based on the Shopify domain or other criteria. By implementing a method that returns a boolean, the developer can implement their own logic.

Just a thought.

tobiasdalhof commented 6 years ago

@brianakidd You can already do that by setting grandfathered to true and use the isGrandfathered method on your shop model. Am I missing something here?

https://github.com/ohmybrew/laravel-shopify/blob/master/src/ShopifyApp/Models/Shop.php#L62

I still struggle to find a proper place for this kind of actions.

brianakidd commented 6 years ago

@tobiasdalhof Yes, you can, but during installation, the shop record doesn't exist yet so currently I'm starting the installation process, once the shop is created in the database, then I set the flag, then I continue the installation process.

My point is rather than explicitly setting it based on the shop plan or some other specific criteria, let the developer make the decision. This is what happens when implementing a Policy, for example - we're not using configuration to determine if a user can perform a certain action, we just implement a method that returns true or false.

Seems like letting the developer decide in this manner will keep the project from getting bloated.

tobiasdalhof commented 6 years ago

Ah, that makes sense...

Hmm.. How about adding events to the installation process? Something like BeforeInstallation and AfterInstallation?

Edit: We could listen to Eloquent Events https://laravel.com/docs/5.6/eloquent#events

Example with Observer:

Listen for creating and created

<?php

namespace App\Observers;

use OhMyBrew\ShopifyApp\Facades\ShopifyApp;
use OhMyBrew\ShopifyApp\Models\Shop;

class ShopObserver
{
    /**
     * Before installation.
     * 
     * @param  \OhMyBrew\ShopifyApp\Models\Shop  $shop
     * @return void
     */
    public function creating(Shop $shop)
    {
        $api = ShopifyApp::shop()->api();
        $shopifyPlanName = $api->rest('GET', '/admin/shop.json')->body->shop->plan_name;

        if (
            $shopifyPlanName === 'affiliate' ||
            $shopifyPlanName === 'staff_business'
        ) {
            // Free access for partners and Shopify employees
            $shop->grandfathered = true;
        }
    }

    /**
     * After installation.
     * 
     * @param  \OhMyBrew\ShopifyApp\Models\Shop  $shop
     * @return void
     */
    public function created(Shop $shop)
    {
        //
    }
}

Boot ShopObserver in your AppServiceProvider

<?php

namespace App\Providers;

use App\Observers\ShopObserver;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        \OhMyBrew\ShopifyApp\Models\Shop::observe(ShopObserver::class);
    }

    /**
     * Register the service provider.
     *
     * @return void
     */
    public function register()
    {
        //
    }
}

Just an idea - not tested. This wouldn't require any changes to the package. What do you think? @brianakidd @ohmybrew

gnikyt commented 6 years ago

I like the idea of the events, yes it would keep things not so bloated then and give full freedom to the developer. Any recommendations on a simple event system for Laravel? Would rather not roll my own if one exists..

On Wed., Aug. 8, 2018, 7:31 a.m. Tobias Dalhof, notifications@github.com wrote:

Ah, that makes sense...

Hmm.. How about adding events to the installation process? Something like BeforeInstallation and AfterInstallation?

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/ohmybrew/laravel-shopify/issues/63#issuecomment-411354053, or mute the thread https://github.com/notifications/unsubscribe-auth/ACTuOunaeDyQ-x_Wu4Od_5h1gECyJnzsks5uOrb8gaJpZM4VxsT3 .

tobiasdalhof commented 6 years ago

ping @ohmybrew i have updated my last post; the quoted message in your mail is outdated

gnikyt commented 6 years ago

@tobiasdalhof Ahh yes, damn email. Observers... completely forgot about them! Works well in my mind for the use-case.

I could go the wiki route with this and provide a page on how to do this, or I can also provide an observer by default which is enabled through config... the latter would certainly be easier for someone setting up the package out of the box... what do you think?

tobiasdalhof commented 6 years ago

Honestly i'd just provide some nice documentation about this topic and keep the package lean

gnikyt commented 6 years ago

@tobiasdalhof Yeah, true, thats been my goal. I'll write up something nice based on your above, thanks kindly 🥇, will post it here once complete and close the issue (unless someone else has anything to add that is :))

brianakidd commented 6 years ago

@tobiasdalhof Yes, I like listening for the event.

@ohmybrew Thanks for considering this.

tobiasdalhof commented 6 years ago

I ran into trouble when observing the creating and created events.

$shop->api() will fail because the shopify_token column is empty at this point. In my case I have to observe the saved event and execute my code after shopify_token is added by AuthController.

I also added a finished_setup flag to my shops table:

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class AddFinishedSetupToShopsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('shops', function (Blueprint $table) {
            $table->boolean('finished_setup')->default(false);
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('shops', function (Blueprint $table) {
            $table->dropColumn('finished_setup');
        });
    }
}

Now I am able to observe the saved event on Shop in order to initialize my app.

<?php

namespace App\Observers;

use App\Shop;
use OhMyBrew\ShopifyApp\Facades\ShopifyApp;

class ShopObserver
{
    /**
     * @var object
     */
    public $shopApi;

    /**
     * @param Shop $shop
     * @return void
     */
    public function saved(Shop $shop)
    {
        if (
            !empty($shop->shopify_token) &&
            $shop->finished_setup === false
        ) {
            $shop->grandfathered = $this->allowFreeAccess($shop);
            $this->initUser($shop);
            $shop->finished_setup = true;
            $shop->save();
        } 
    }

    /**
     * @return object
     */
    public function shopApi(Shop $shop)
    {
        if (!empty($this->shopApi)) {
            return $this->shopApi;
        }

        $api = $shop->api();
        $this->shopApi = $api->rest('GET', '/admin/shop.json')->body->shop;

        return $this->shopApi;
    }

    /**
     * @param Shop $shop
     * @return void
     */
    public function initUser(Shop $shop)
    {
        $shopApi = $this->shopApi($shop);

        $user = $shop->user()->create([
            'name' => $shopApi->shop_owner,
            'email' => $shopApi->email,
            'timezone' => $shopApi->iana_timezone,
            'template_default' => 'test',
        ]);
    }

    /**
     * @return boolean
     */
    public function allowFreeAccess(Shop $shop)
    {
        $shopifyPlanName = $this->shopApi($shop)->plan_name;

        if (
            $shopifyPlanName === 'affiliate' ||
            $shopifyPlanName === 'staff_business'
        ) {
            // Free access for partners and Shopify employees
            return true;
        }

        return false;
    }
}
gnikyt commented 6 years ago

Ah right, we need a token to do API calls...

There is an afterAuthenticateJob feature built into the code which fires after every auth. Line 147 of AuthControllerTrait handles this. It passed the Shop object into the job, if you tell the config to run your job inline, it will run right away. Which will happen before billing screen. I think maybe this is the best spot to do this free-business in since we'd have the token at that point.

    /*
    |--------------------------------------------------------------------------
    | After Authenticate Job
    |--------------------------------------------------------------------------
    |
    | This option is for firing a job after a shop has been authenticated.
    | This, like webhooks and scripttag jobs, will fire every time a shop
    | authenticates, not just once.
    |
    */

    'after_authenticate_job' => [
        /*
            'job' => env('AFTER_AUTHENTICATE_JOB'), // example: \App\Jobs\AfterAuthenticateJob::class
            'inline' => env('AFTER_AUTHENTICATE_JOB_INLINE', false) // False = execute inline, true = dispatch job for later
        */
    ],

If you do something like:

    'after_authenticate_job' => [
            'job' => \App\Jobs\AfterAuthenticateJob::class
            'inline' => true
    ],

Then your job with:

<?php

namespace App\Jobs;

use OhMyBrew\ShopifyApp\Models\Shop;

class AfterAuthenticateJob
{
    /**
     * Shop's instance.
     *
     * @var string
     */
    protected $shop;

    /**
     * Create a new job instance.
     *
     * @param object $shop The shop's object
     *
     * @return void
     */
    public function __construct(Shop $shop)
    {
        $this->shop = $shop;
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        if (!$this->shop->isGrandfathered()) {
            $planName = $this->shop->api()->rest('GET', '/admin/shop.json')->body->shop->plan_name;
            if ($planName === 'affiliate' || $planName === 'staff_business') {
                 $shop->grandfathered = true;
                 $shop->save();
            }
        }

       /*
         * OR with `finished_setup` so we don't redo this logic every auth...
         *
         * if (!$this->shop->finished_setup && !$this->shop->isGrandfathered()) {
         *   $planName = $this->shop->api()->rest('GET', '/admin/shop.json')->body->shop->plan_name;
         *   if ($planName === 'affiliate' || $planName === 'staff_business') {
         *        $shop->grandfathered = true;
         *        $shop->finished_setup = true;
         *        $shop->save();
         *   }
         * }
        */
    }
}

Maybe keep your finished_setup column so you can not run the code in handle() if setup is done.. just an idea off the top of my head...

tobiasdalhof commented 6 years ago

Oh this is great! Thanks for the hint. I will move my code to AfterAuthenticateJob then.

gnikyt commented 6 years ago

Great - yeah, oversight - completely forgot we need a token, I'll update my wiki draft for this and get it published soon.

gnikyt commented 6 years ago

Closing per: https://github.com/ohmybrew/laravel-shopify/wiki/Free-Access-Based-on-Shopify-Plan

Thank you everyone.

kushal-gandhi commented 4 years ago

@osiset @tobidalhof I want to give free access to the app for development stores, but when they transfer ownership of the store to the client and client choose a paid plan, I want to force them to buy a plan to use the app. Can you tell me what can I keep checking in that case and redirect them to billing?

sp-artisan commented 1 year ago

@osiset @tobidalhof I want to give free access to the app for development stores, but when they transfer ownership of the store to the client and client choose a paid plan, I want to force them to buy a plan to use the app. Can you tell me what can I keep checking in that case and redirect them to billing?

Have you solved it Me also getting same issue ?

tobiasdalhof commented 1 year ago

@osiset @tobidalhof I want to give free access to the app for development stores, but when they transfer ownership of the store to the client and client choose a paid plan, I want to force them to buy a plan to use the app. Can you tell me what can I keep checking in that case and redirect them to billing?

Have you solved it Me also getting same issue ?

Not using this package anymore

sp-artisan commented 1 year ago

@osiset @tobidalhof I want to give free access to the app for development stores, but when they transfer ownership of the store to the client and client choose a paid plan, I want to force them to buy a plan to use the app. Can you tell me what can I keep checking in that case and redirect them to billing?

Have you solved it Me also getting same issue ?

Not using this package anymore

Then what do you use ? Can you suggest me something better then this package. And you may have solved this issue can you please tell me how you did that ?

sp-artisan commented 1 year ago

@osiset @tobidalhof I want to give free access to the app for development stores, but when they transfer ownership of the store to the client and client choose a paid plan, I want to force them to buy a plan to use the app. Can you tell me what can I keep checking in that case and redirect them to billing?

Hey Kushal , Can you please tell me how did you solved.