Skip to content
This repository has been archived by the owner on Nov 30, 2022. It is now read-only.

Commit

Permalink
Merge pull request #103 from ohmybrew/76-multi-plans
Browse files Browse the repository at this point in the history
Multi Billing Plan Support
  • Loading branch information
gnikyt authored Sep 14, 2018
2 parents 2572975 + 0fc583b commit 389e8b4
Show file tree
Hide file tree
Showing 39 changed files with 985 additions and 393 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,13 @@ For more information, tutorials, etc., please view the project's [wiki](https://
- [x] Provide assistance in developing Shopify apps with Laravel
- [x] Integration with Shopify API
- [x] Authentication & installation for shops
- [x] Billing integration built-in for single and recurring application charges
- [x] Plan & billing integration for single, recurring, and usage-types
- [x] Tracking charges to a shop (recurring, single, usage, etc) with trial support
- [x] Auto install app webhooks and scripttags thorugh background jobs
- [x] Provide basic ESDK views
- [x] Handles and processes incoming webhooks
- [x] Handles and verifies incoming app proxy requests
- [x] Namespacing abilities to run multiple apps on the same database

## Documentation

Expand Down
106 changes: 41 additions & 65 deletions src/ShopifyApp/Libraries/BillingPlan.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace OhMyBrew\ShopifyApp\Libraries;

use Exception;
use OhMyBrew\ShopifyApp\Models\Plan;
use OhMyBrew\ShopifyApp\Models\Shop;

class BillingPlan
Expand All @@ -15,11 +16,11 @@ class BillingPlan
protected $shop;

/**
* The plan details for Shopify.
* The plan to use.
*
* @var array
* @var \OhMyBrew\ShopifyApp\Models\Plan
*/
protected $details;
protected $plan;

/**
* The charge ID.
Expand All @@ -28,48 +29,18 @@ class BillingPlan
*/
protected $chargeId;

/**
* The charge type.
*
* @var string
*/
protected $chargeType;

/**
* Constructor for billing plan class.
*
* @param Shop $shop The shop to target for billing.
* @param string $chargeType The type of charge for the plan (single or recurring).
* @param Shop $shop The shop to target for billing.
* @param Plan $plan The plan from the database.
*
* @return $this
*/
public function __construct(Shop $shop, string $chargeType = 'recurring')
public function __construct(Shop $shop, Plan $plan)
{
$this->shop = $shop;
$this->chargeType = $chargeType === 'single' ? 'application_charge' : 'recurring_application_charge';

return $this;
}

/**
* Sets the plan.
*
* @param array $plan The plan details.
* $plan = [
* 'name' => (string) Plan name.
* 'price' => (float) Plan price. Required.
* 'test' => (boolean) Test mode or not.
* 'trial_days' => (int) Plan trial period in days.
* 'return_url' => (string) URL to handle response for acceptance or decline or billing. Required.
* 'capped_amount' => (float) Capped price if using UsageCharge API.
* 'terms' => (string) Terms for the usage. Required if using capped_amount.
* ]
*
* @return $this
*/
public function setDetails(array $details)
{
$this->details = $details;
$this->plan = $plan;

return $this;
}
Expand Down Expand Up @@ -103,15 +74,15 @@ public function getCharge()
// Run API to grab details
return $this->shop->api()->rest(
'GET',
"/admin/{$this->chargeType}s/{$this->chargeId}.json"
)->body->{$this->chargeType};
"/admin/{$this->plan->typeAsString(true)}/{$this->chargeId}.json"
)->body->{$this->plan->typeAsString()};
}

/**
* Activates a plan to the shop.
*
* Example usage:
* (new BillingPlan([shop], 'recurring'))->setChargeId(request('charge_id'))->activate();
* (new BillingPlan([shop], [plan]))->setChargeId(request('charge_id'))->activate();
*
* @return object
*/
Expand All @@ -125,47 +96,52 @@ public function activate()
// Activate and return the API response
return $this->shop->api()->rest(
'POST',
"/admin/{$this->chargeType}s/{$this->chargeId}/activate.json"
)->body->{$this->chargeType};
"/admin/{$this->plan->typeAsString(true)}/{$this->chargeId}/activate.json"
)->body->{$this->plan->typeAsString()};
}

/**
* Gets the confirmation URL to redirect the customer to.
* This URL sends them to Shopify's billing page.
*
* Example usage:
* (new BillingPlan([shop], 'recurring'))->setDetails($plan)->getConfirmationUrl();
* Returns the charge params sent with the post request.
*
* @return string
* @return array
*/
public function getConfirmationUrl()
public function getChargeParams()
{
// Check if we have plan details
if (!is_array($this->details)) {
throw new Exception('Plan details are missing for confirmation URL request.');
}

// Build the charge array
$chargeDetails = [
'test' => isset($this->details['test']) ? $this->details['test'] : false,
'trial_days' => isset($this->details['trial_days']) ? $this->details['trial_days'] : 0,
'name' => $this->details['name'],
'price' => $this->details['price'],
'return_url' => $this->details['return_url'],
'test' => $this->plan->isTest(),
'trial_days' => $this->plan->hasTrial() ? $this->plan->trial_days : 0,
'name' => $this->plan->name,
'price' => $this->plan->price,
'return_url' => secure_url(config('shopify-app.billing_redirect'), ['plan_id' => $this->plan->id]),
];

// Handle capped amounts for UsageCharge API
if (isset($this->details['capped_amount'])) {
$chargeDetails['capped_amount'] = $this->details['capped_amount'];
$chargeDetails['terms'] = $this->details['terms'];
if (isset($this->plan->capped_amount)) {
$chargeDetails['capped_amount'] = $this->plan->capped_amount;
$chargeDetails['terms'] = $this->plan->terms;
}

return $chargeDetails;
}

/**
* Gets the confirmation URL to redirect the customer to.
* This URL sends them to Shopify's billing page.
*
* Example usage:
* (new BillingPlan([shop], [plan]))->setDetails($plan)->getConfirmationUrl();
*
* @return string
*/
public function getConfirmationUrl()
{
// Begin the charge request
$charge = $this->shop->api()->rest(
'POST',
"/admin/{$this->chargeType}s.json",
["{$this->chargeType}" => $chargeDetails]
)->body->{$this->chargeType};
"/admin/{$this->plan->typeAsString(true)}.json",
["{$this->plan->typeAsString()}" => $this->getChargeParams()]
)->body->{$this->plan->typeAsString()};

return $charge->confirmation_url;
}
Expand Down
14 changes: 2 additions & 12 deletions src/ShopifyApp/Middleware/AuthProxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,13 @@ class AuthProxy
*/
public function handle(Request $request, Closure $next)
{
// Grab the data we need
// Grab the data we need, remove signature since its not part of the signature calculation
$query = request()->all();
$signature = $query['signature'];

// Remove signature since its not part of the signature calculation, sort it
unset($query['signature']);
ksort($query);

// Build a query string without query characters
$queryCompiled = [];
foreach ($query as $key => $value) {
$queryCompiled[] = "{$key}=".(is_array($value) ? implode($value, ',') : $value);
}
$queryJoined = implode($queryCompiled, '');

// Build a local signature
$signatureLocal = hash_hmac('sha256', $queryJoined, config('shopify-app.api_secret'));
$signatureLocal = ShopifyApp::createHmac(['data' => $query, 'buildQuery' => true]);
if ($signature !== $signatureLocal || !isset($query['shop'])) {
// Issue with HMAC or missing shop header
abort(401, 'Invalid proxy signature');
Expand Down
4 changes: 2 additions & 2 deletions src/ShopifyApp/Middleware/AuthWebhook.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

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

class AuthWebhook
{
Expand All @@ -21,8 +22,7 @@ public function handle(Request $request, Closure $next)
$shop = request()->header('x-shopify-shop-domain');
$data = request()->getContent();

// From https://help.shopify.com/api/getting-started/webhooks#verify-webhook
$hmacLocal = base64_encode(hash_hmac('sha256', $data, config('shopify-app.api_secret'), true));
$hmacLocal = ShopifyApp::createHmac(['data' => $data, 'raw' => true, 'encode' => true]);
if (!hash_equals($hmac, $hmacLocal) || empty($shop)) {
// Issue with HMAC or missing shop header
abort(401, 'Invalid webhook signature');
Expand Down
11 changes: 1 addition & 10 deletions src/ShopifyApp/Middleware/Billable.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,8 @@ class Billable
public function handle(Request $request, Closure $next)
{
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())
) {
if (!$shop->isFreemium() && !$shop->isGrandfathered() && !$shop->plan) {
// They're not grandfathered in, and there is no charge or charge was declined... redirect to billing
return redirect()->route('billing');
}
Expand Down
36 changes: 36 additions & 0 deletions src/ShopifyApp/Models/Charge.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,32 @@ class Charge extends Model
const CHARGE_USAGE = 3;
const CHARGE_CREDIT = 4;

/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'type',
'shop_id',
'charge_id',
'plan_id',
];

/**
* The attributes that should be casted to native types.
*
* @var array
*/
protected $casts = [
'type' => 'int',
'test' => 'bool',
'charge_id' => 'int',
'shop_id' => 'int',
'capped_amount' => 'float',
'price' => 'float',
];

/**
* The attributes that should be mutated to dates.
*
Expand All @@ -33,6 +59,16 @@ public function shop()
return $this->belongsTo('OhMyBrew\ShopifyApp\Models\Shop');
}

/**
* Gets the plan.
*
* @return \OhMyBrew\ShopifyApp\Models\Plan
*/
public function plan()
{
return $this->belongsTo('OhMyBrew\ShopifyApp\Models\Plan');
}

/**
* Gets the charge's data from Shopify.
*
Expand Down
88 changes: 88 additions & 0 deletions src/ShopifyApp/Models/Plan.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

namespace OhMyBrew\ShopifyApp\Models;

use Illuminate\Database\Eloquent\Model;

class Plan extends Model
{
// Types of plans
const PLAN_RECURRING = 1;
const PLAN_ONETIME = 2;

/**
* The attributes that should be casted to native types.
*
* @var array
*/
protected $casts = [
'type' => 'int',
'test' => 'bool',
'on_install' => 'bool',
'capped_amount' => 'float',
'price' => 'float',
];

/**
* Get charges.
*
* @return \Illuminate\Database\Eloquent\Collection
*/
public function charges()
{
return $this->hasMany('OhMyBrew\ShopifyApp\Models\Charge');
}

/**
* Returns the plan type as a string (for API).
*
* @param bool $plural Return the plural form or not.
*
* @return string
*/
public function typeAsString($plural = false)
{
$type = null;
switch ($this->type) {
case self::PLAN_ONETIME:
$type = 'application_charge';
break;
default:
case self::PLAN_RECURRING:
$type = 'recurring_application_charge';
break;
}

return $plural ? "{$type}s" : $type;
}

/**
* Checks if this plan has a trial.
*
* @return bool
*/
public function hasTrial()
{
return $this->trial_days !== null && $this->trial_days > 0;
}

/**
* Checks if this plan should be presented on install.
*
* @return bool
*/
public function isOnInstall()
{
return (bool) $this->on_install;
}

/**
* Checks if the plan is a test.
*
* @return bool
*/
public function isTest()
{
return (bool) $this->test;
}
}
Loading

0 comments on commit 389e8b4

Please sign in to comment.