diff --git a/README.md b/README.md
index 5a347a68..762d7396 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/src/ShopifyApp/Libraries/BillingPlan.php b/src/ShopifyApp/Libraries/BillingPlan.php
index 3a202067..71baaf05 100644
--- a/src/ShopifyApp/Libraries/BillingPlan.php
+++ b/src/ShopifyApp/Libraries/BillingPlan.php
@@ -3,6 +3,7 @@
namespace OhMyBrew\ShopifyApp\Libraries;
use Exception;
+use OhMyBrew\ShopifyApp\Models\Plan;
use OhMyBrew\ShopifyApp\Models\Shop;
class BillingPlan
@@ -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.
@@ -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;
}
@@ -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
*/
@@ -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;
}
diff --git a/src/ShopifyApp/Middleware/AuthProxy.php b/src/ShopifyApp/Middleware/AuthProxy.php
index 7bef19d9..d9359a11 100644
--- a/src/ShopifyApp/Middleware/AuthProxy.php
+++ b/src/ShopifyApp/Middleware/AuthProxy.php
@@ -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');
diff --git a/src/ShopifyApp/Middleware/AuthWebhook.php b/src/ShopifyApp/Middleware/AuthWebhook.php
index 8615da34..943d06fc 100644
--- a/src/ShopifyApp/Middleware/AuthWebhook.php
+++ b/src/ShopifyApp/Middleware/AuthWebhook.php
@@ -4,6 +4,7 @@
use Closure;
use Illuminate\Http\Request;
+use OhMyBrew\ShopifyApp\Facades\ShopifyApp;
class AuthWebhook
{
@@ -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');
diff --git a/src/ShopifyApp/Middleware/Billable.php b/src/ShopifyApp/Middleware/Billable.php
index 3d85557d..0a27be57 100644
--- a/src/ShopifyApp/Middleware/Billable.php
+++ b/src/ShopifyApp/Middleware/Billable.php
@@ -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');
}
diff --git a/src/ShopifyApp/Models/Charge.php b/src/ShopifyApp/Models/Charge.php
index 34912e6e..3b2caacb 100644
--- a/src/ShopifyApp/Models/Charge.php
+++ b/src/ShopifyApp/Models/Charge.php
@@ -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.
*
@@ -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.
*
diff --git a/src/ShopifyApp/Models/Plan.php b/src/ShopifyApp/Models/Plan.php
new file mode 100644
index 00000000..8c4edac0
--- /dev/null
+++ b/src/ShopifyApp/Models/Plan.php
@@ -0,0 +1,88 @@
+ '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;
+ }
+}
diff --git a/src/ShopifyApp/Models/Shop.php b/src/ShopifyApp/Models/Shop.php
index eddd1d54..c651f2b9 100644
--- a/src/ShopifyApp/Models/Shop.php
+++ b/src/ShopifyApp/Models/Shop.php
@@ -23,6 +23,16 @@ class Shop extends Model
'namespace',
];
+ /**
+ * The attributes that should be casted to native types.
+ *
+ * @var array
+ */
+ protected $casts = [
+ 'grandfathered' => 'bool',
+ 'freemium' => 'bool',
+ ];
+
/**
* The attributes that should be mutated to dates.
*
@@ -97,4 +107,24 @@ public function hasCharges()
{
return $this->charges->isNotEmpty();
}
+
+ /**
+ * Gets the plan.
+ *
+ * @return \OhMyBrew\ShopifyApp\Models\Plan
+ */
+ public function plan()
+ {
+ return $this->belongsTo('OhMyBrew\ShopifyApp\Models\Plan');
+ }
+
+ /**
+ * Checks if the shop is freemium.
+ *
+ * @return bool
+ */
+ public function isFreemium()
+ {
+ return ((bool) $this->freemium) === true;
+ }
}
diff --git a/src/ShopifyApp/Observers/ShopObserver.php b/src/ShopifyApp/Observers/ShopObserver.php
index 006b4f1e..e3215b87 100644
--- a/src/ShopifyApp/Observers/ShopObserver.php
+++ b/src/ShopifyApp/Observers/ShopObserver.php
@@ -19,5 +19,10 @@ public function creating(Shop $shop)
// Automatically add the current namespace to new records
$shop->namespace = config('shopify-app.namespace');
}
+
+ if (config('shopify-app.billing_freemium_enabled') === true && !isset($shop->freemium)) {
+ // Add the freemium flag to the shop
+ $shop->freemium = true;
+ }
}
}
diff --git a/src/ShopifyApp/ShopifyApp.php b/src/ShopifyApp/ShopifyApp.php
index fd121748..93a6cf64 100644
--- a/src/ShopifyApp/ShopifyApp.php
+++ b/src/ShopifyApp/ShopifyApp.php
@@ -92,4 +92,37 @@ public function sanitizeShopDomain($domain)
// Return the host after cleaned up
return parse_url("http://{$domain}", PHP_URL_HOST);
}
+
+ /**
+ * HMAC creation helper.
+ *
+ * @param array $opts
+ *
+ * @return string
+ */
+ public function createHmac(array $opts)
+ {
+ // Setup defaults
+ $data = $opts['data'];
+ $raw = $opts['raw'] ?? false;
+ $buildQuery = $opts['buildQuery'] ?? false;
+ $encode = $opts['encode'] ?? false;
+ $secret = $opts['secret'] ?? config('shopify-app.api_secret');
+
+ if ($buildQuery) {
+ //Query params must be sorted and compiled
+ ksort($data);
+ $queryCompiled = [];
+ foreach ($data as $key => $value) {
+ $queryCompiled[] = "{$key}=".(is_array($value) ? implode($value, ',') : $value);
+ }
+ $data = implode($queryCompiled, '');
+ }
+
+ // Create the hmac all based on the secret
+ $hmac = hash_hmac('sha256', $data, $secret, $raw);
+
+ // Return based on options
+ return $encode ? base64_encode($hmac) : $hmac;
+ }
}
diff --git a/src/ShopifyApp/Traits/BillingControllerTrait.php b/src/ShopifyApp/Traits/BillingControllerTrait.php
index 8a44db9f..5b812863 100644
--- a/src/ShopifyApp/Traits/BillingControllerTrait.php
+++ b/src/ShopifyApp/Traits/BillingControllerTrait.php
@@ -6,6 +6,7 @@
use OhMyBrew\ShopifyApp\Facades\ShopifyApp;
use OhMyBrew\ShopifyApp\Libraries\BillingPlan;
use OhMyBrew\ShopifyApp\Models\Charge;
+use OhMyBrew\ShopifyApp\Models\Plan;
use OhMyBrew\ShopifyApp\Models\Shop;
trait BillingControllerTrait
@@ -13,51 +14,57 @@ trait BillingControllerTrait
/**
* Redirects to billing screen for Shopify.
*
+ * @param int|null $planId The plan's ID.
+ *
* @return \Illuminate\Http\Response
*/
- public function index()
+ public function index($planId = null)
{
// Get the confirmation URL
$shop = ShopifyApp::shop();
- $plan = new BillingPlan($shop, $this->chargeType());
- $plan->setDetails($this->planDetails($shop));
+ $billingPlan = new BillingPlan($shop, $this->getPlan($planId));
// Do a fullpage redirect
return view('shopify-app::billing.fullpage_redirect', [
- 'url' => $plan->getConfirmationUrl(),
+ 'url' => $billingPlan->getConfirmationUrl(),
]);
}
/**
* Processes the response from the customer.
*
- * @return void
+ * @param int|null $planId The plan's ID.
+ *
+ * @return \Illuminate\Http\Response
*/
- public function process()
+ public function process($planId = null)
{
// Setup the shop and get the charge ID passed in
$shop = ShopifyApp::shop();
$chargeId = request('charge_id');
// Setup the plan and get the charge
- $plan = new BillingPlan($shop, $this->chargeType());
- $plan->setChargeId($chargeId);
- $status = $plan->getCharge()->status;
+ $plan = $this->getPlan($planId);
+ $billingPlan = new BillingPlan($shop, $plan);
+ $billingPlan->setChargeId($chargeId);
+ $status = $billingPlan->getCharge()->status;
// Grab the plan detailed used
- $planDetails = $this->planDetails($shop);
+ $planDetails = $billingPlan->getChargeParams();
unset($planDetails['return_url']);
// Create a charge (regardless of the status)
- $charge = new Charge();
- $charge->type = $this->chargeType() === 'recurring' ? Charge::CHARGE_RECURRING : Charge::CHARGE_ONETIME;
- $charge->charge_id = $chargeId;
- $charge->status = $status;
+ $charge = Charge::firstOrNew([
+ 'type' => $plan->type,
+ 'shop_id' => $shop->id,
+ 'plan_id' => $plan->id,
+ 'charge_id' => $chargeId,
+ ]);
// Check the customer's answer to the billing
if ($status === 'accepted') {
// Activate and add details to our charge
- $response = $plan->activate();
+ $response = $billingPlan->activate();
$charge->status = $response->status;
$charge->billing_on = $response->billing_on;
$charge->trial_ends_on = $response->trial_ends_on;
@@ -79,68 +86,92 @@ public function process()
foreach ($planDetails as $key => $value) {
$charge->{$key} = $value;
}
-
- // Save and link to the shop
- $shop->charges()->save($charge);
+ $charge->save();
if ($status === 'declined') {
// Show the error... don't allow access
- return abort(403, 'It seems you have declined the billing charge for this application');
+ return view('shopify-app::billing.error', ['message' => 'It seems you have declined the billing charge for this application']);
}
- // All good... go to homepage of app
+ // All good, update the shop's plan and take them off freeium (if applicable)
+ $shop->freemium = false;
+ $shop->plan_id = $plan->id;
+ $shop->save();
+
+ // Go to homepage of app
return redirect()->route('home');
}
/**
- * Base plan to use for billing. Setup as a function so its patchable.
- * Checks for cancelled charge within trial day limit, and issues
- * a new trial days number depending on the result for shops who
- * resinstall the app.
+ * Allows for setting a usage charge.
*
- * @param object $shop The shop object.
- *
- * @return array
+ * @return \Illuminate\Http\Response
*/
- protected function planDetails(Shop $shop)
+ public function usageCharge()
{
- // Initial plan details
- $plan = [
- 'name' => config('shopify-app.billing_plan'),
- 'price' => config('shopify-app.billing_price'),
- 'test' => config('shopify-app.billing_test'),
- 'return_url' => url(config('shopify-app.billing_redirect')),
- ];
-
- // Handle capped amounts for UsageCharge API
- if (config('shopify-app.billing_capped_amount')) {
- $plan['capped_amount'] = config('shopify-app.billing_capped_amount');
- $plan['terms'] = config('shopify-app.billing_terms');
+ $shop = ShopifyApp::shop();
+ $lastCharge = $this->getLastCharge($shop);
+
+ if ($lastCharge->type !== Charge::CHARGE_RECURRING) {
+ // Charge is not recurring
+ return view('shopify-app::billing.error', ['message' => 'Can only create usage charges for recurring charge']);
}
- // Grab the last charge for the shop (if any) to determine if this shop
- // reinstalled the app so we can issue new trial days based on result
- $lastCharge = $this->getLastCharge($shop);
- if ($lastCharge && $lastCharge->isCancelled()) {
- // Return the new trial days, could result in 0
- $plan['trial_days'] = $lastCharge->remainingTrialDaysFromCancel();
- } else {
- // Set initial trial days fromc config
- $plan['trial_days'] = config('shopify-app.billing_trial_days');
+ // Get the input values needed
+ $data = request()->only(['price', 'description', 'redirect', 'signature']);
+ $signature = $data['signature'];
+ unset($data['signature']);
+
+ // Confirm the charge hasn't been tampered with
+ $signatureLocal = ShopifyApp::createHmac(['data' => $data, 'buildQuery' => true]);
+ if (!hash_equals($signature, $signatureLocal)) {
+ // Possible tampering
+ return view('shopify-app::billing.error', ['message' => 'Issue in creating usgae charge']);
}
- return $plan;
+ // Create the charge via API
+ $usageCharge = $shop->api()->rest(
+ 'POST',
+ "/admin/recurring_application_charges/{$lastCharge->charge_id}/usage_charges.json",
+ [
+ 'usage_charge' => [
+ 'price' => $data['price'],
+ 'description' => $data['description'],
+ ],
+ ]
+ )->body->usage_charge;
+
+ // Create the charge in the database referencing the recurring charge
+ $charge = new Charge();
+ $charge->type = Charge::CHARGE_USAGE;
+ $charge->shop_id = $shop->id;
+ $charge->reference_charge = $lastCharge->charge_id;
+ $charge->charge_id = $usageCharge->id;
+ $charge->price = $usageCharge->price;
+ $charge->description = $usageCharge->description;
+ $charge->billing_on = $usageCharge->billing_on;
+ $charge->save();
+
+ // All done, return with success
+ return isset($data['redirect']) ? redirect($data['redirect']) : redirect()->back()->with('success', true);
}
/**
- * Base charge type (single or recurring).
- * Setup as a function so its patchable.
+ * Get the plan to use.
+ *
+ * @param int|null $planId The plan's ID.
*
- * @return string
+ * @return Plan
*/
- protected function chargeType()
+ protected function getPlan($planId = null)
{
- return config('shopify-app.billing_type');
+ if ($planId === null) {
+ // Find the on-install plan
+ return Plan::where('on_install', true)->first();
+ }
+
+ // Find the plan passed to the method
+ return Plan::where('id', $planId)->first();
}
/**
@@ -154,6 +185,7 @@ protected function getLastCharge(Shop $shop)
{
return $shop->charges()
->whereIn('type', [Charge::CHARGE_RECURRING, Charge::CHARGE_ONETIME])
+ ->where('plan_id', $shop->plan_id)
->orderBy('created_at', 'desc')
->first();
}
diff --git a/src/ShopifyApp/resources/config/shopify-app.php b/src/ShopifyApp/resources/config/shopify-app.php
index 21dda094..2b69b635 100644
--- a/src/ShopifyApp/resources/config/shopify-app.php
+++ b/src/ShopifyApp/resources/config/shopify-app.php
@@ -130,60 +130,15 @@
/*
|--------------------------------------------------------------------------
- | Billing Type
+ | Enable Freemium Mode
|--------------------------------------------------------------------------
|
- | Single charge or recurring charge.
- | Simply use "single" for single, and "recurring" for recurring.
+ | Allow a shop use the app in "freemium" mode.
+ | Shop will get a `freemium` flag on their record in the table.
|
*/
- 'billing_type' => env('SHOPIFY_BILLING_TYPE', 'recurring'),
-
- /*
- |--------------------------------------------------------------------------
- | Billing Plan Name
- |--------------------------------------------------------------------------
- |
- | The name of the plan which shows on the billing.
- |
- */
-
- 'billing_plan' => env('SHOPIFY_BILLING_PLAN_NAME', 'Base Plan'),
-
- /*
- |--------------------------------------------------------------------------
- | Billing Price
- |--------------------------------------------------------------------------
- |
- | The single or recurring price to charge the customer.
- |
- */
-
- 'billing_price' => (float) env('SHOPIFY_BILLING_PRICE', 0.00),
-
- /*
- |--------------------------------------------------------------------------
- | Billing Trial
- |--------------------------------------------------------------------------
- |
- | Trails days for the app. Set to 0 for no trial period.
- |
- */
-
- 'billing_trial_days' => (int) env('SHOPIFY_BILLING_TRIAL_DAYS', 7),
-
- /*
- |--------------------------------------------------------------------------
- | Billing Test
- |--------------------------------------------------------------------------
- |
- | Enable or disable test mode for billing.
- | This is useful for development purposes, see Shopify's documentation.
- |
- */
-
- 'billing_test' => (bool) env('SHOPIFY_BILLING_TEST', false),
+ 'billing_freemium_enabled' => (bool) env('SHOPIFY_BILLING_FREEMIUM_ENABLED', false),
/*
|--------------------------------------------------------------------------
@@ -197,28 +152,6 @@
'billing_redirect' => env('SHOPIFY_BILLING_REDIRECT', '/billing/process'),
- /*
- |--------------------------------------------------------------------------
- | Billing Capped Amount
- |--------------------------------------------------------------------------
- |
- | The capped price for charging a customer when using the UsageCharge API.
- |
- */
-
- 'billing_capped_amount' => env('SHOPIFY_BILLING_CAPPED_AMOUNT'),
-
- /*
- |--------------------------------------------------------------------------
- | Billing Terms
- |--------------------------------------------------------------------------
- |
- | Terms for the usage. Required if using capped amount.
- |
- */
-
- 'billing_terms' => env('SHOPIFY_BILLING_TERMS'),
-
/*
|--------------------------------------------------------------------------
| Shopify Webhooks
diff --git a/src/ShopifyApp/resources/database/migrations/2018_08_31_153154_create_plans_table.php b/src/ShopifyApp/resources/database/migrations/2018_08_31_153154_create_plans_table.php
new file mode 100644
index 00000000..ec3e0fd4
--- /dev/null
+++ b/src/ShopifyApp/resources/database/migrations/2018_08_31_153154_create_plans_table.php
@@ -0,0 +1,57 @@
+increments('id');
+
+ // The type of plan, either Plan::CHARGE_RECURRING (1) or Plan::CHARGE_ONETIME (2)
+ $table->integer('type');
+
+ // Name of the plan
+ $table->string('name');
+
+ // Price of the plan
+ $table->decimal('price', 8, 2);
+
+ // Store the amount of the charge, this helps if you are experimenting with pricing
+ $table->decimal('capped_amount', 8, 2)->nullable();
+
+ // Terms for the usage charges
+ $table->string('terms')->nullable();
+
+ // Nullable in case of 0 trial days
+ $table->integer('trial_days')->nullable();
+
+ // Is a test plan or not
+ $table->boolean('test')->default(false);
+
+ // On-install
+ $table->boolean('on_install')->default(false);
+
+ // Provides created_at && updated_at columns
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::drop('plans');
+ }
+}
diff --git a/src/ShopifyApp/resources/database/migrations/2018_08_31_154001_add_plan_to_shops_table_and_charges_table.php b/src/ShopifyApp/resources/database/migrations/2018_08_31_154001_add_plan_to_shops_table_and_charges_table.php
new file mode 100644
index 00000000..dccea642
--- /dev/null
+++ b/src/ShopifyApp/resources/database/migrations/2018_08_31_154001_add_plan_to_shops_table_and_charges_table.php
@@ -0,0 +1,44 @@
+integer('plan_id')->unsigned()->nullable();
+ $table->foreign('plan_id')->references('id')->on('plans');
+ });
+
+ Schema::table('shops', function (Blueprint $table) {
+ // Linking
+ $table->integer('plan_id')->unsigned()->nullable();
+ $table->foreign('plan_id')->references('id')->on('plans');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table('charges', function (Blueprint $table) {
+ $table->dropColumn(['plan_id']);
+ });
+
+ Schema::table('shops', function (Blueprint $table) {
+ $table->dropColumn(['plan_id']);
+ });
+ }
+}
diff --git a/src/ShopifyApp/resources/database/migrations/2018_08_31_154502_add_freemium_flag_to_shops_table.php b/src/ShopifyApp/resources/database/migrations/2018_08_31_154502_add_freemium_flag_to_shops_table.php
new file mode 100644
index 00000000..89ac309b
--- /dev/null
+++ b/src/ShopifyApp/resources/database/migrations/2018_08_31_154502_add_freemium_flag_to_shops_table.php
@@ -0,0 +1,32 @@
+boolean('freemium')->default(false);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table('shops', function (Blueprint $table) {
+ $table->dropColumn(['freemium']);
+ });
+ }
+}
diff --git a/src/ShopifyApp/resources/database/migrations/2018_09_11_101333_add_usage_charge_support_to_charges_table.php b/src/ShopifyApp/resources/database/migrations/2018_09_11_101333_add_usage_charge_support_to_charges_table.php
new file mode 100644
index 00000000..ede9b655
--- /dev/null
+++ b/src/ShopifyApp/resources/database/migrations/2018_09_11_101333_add_usage_charge_support_to_charges_table.php
@@ -0,0 +1,29 @@
+string('description')->nullable();
+
+ // Linking to charge_id
+ $table->bigInteger('reference_charge')->nullable();
+ });
+
+ Schema::table('charges', function (Blueprint $table) {
+ // Linking to charge_id, seperate schema block due to contraint issue
+ $table->foreign('reference_charge')->references('charge_id')->on('charges')->onDelete('cascade');
+ });
+ }
+}
diff --git a/src/ShopifyApp/resources/database/migrations/2018_09_12_101645_add_default_to_test_on_charges_table.php b/src/ShopifyApp/resources/database/migrations/2018_09_12_101645_add_default_to_test_on_charges_table.php
new file mode 100644
index 00000000..c3a3214a
--- /dev/null
+++ b/src/ShopifyApp/resources/database/migrations/2018_09_12_101645_add_default_to_test_on_charges_table.php
@@ -0,0 +1,32 @@
+boolean('test')->default(false)->change();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table('charges', function (Blueprint $table) {
+ $table->boolean('test')->default(null)->change();
+ });
+ }
+}
diff --git a/src/ShopifyApp/resources/routes.php b/src/ShopifyApp/resources/routes.php
index 6a3a6e7c..330609ee 100644
--- a/src/ShopifyApp/resources/routes.php
+++ b/src/ShopifyApp/resources/routes.php
@@ -67,9 +67,10 @@
*/
Route::get(
- '/billing',
+ '/billing/{planId?}',
'OhMyBrew\ShopifyApp\Controllers\BillingController@index'
)
+ ->where('planId', '^([0-9]+|)$')
->name('billing');
/*
@@ -82,10 +83,27 @@
*/
Route::get(
- '/billing/process',
+ '/billing/process/{planId?}',
'OhMyBrew\ShopifyApp\Controllers\BillingController@process'
)
+ ->where('planId', '^([0-9]+|)$')
->name('billing.process');
+
+ /*
+ |--------------------------------------------------------------------------
+ | Billing Processor for Usage Charges
+ |--------------------------------------------------------------------------
+ |
+ | Creates a usage charge on a recurring charge.
+ |
+ */
+
+ Route::match(
+ ['get', 'post'],
+ '/billing/usage-charge',
+ 'OhMyBrew\ShopifyApp\Controllers\BillingController@usageCharge'
+ )
+ ->name('billing.usage_charge');
});
Route::group(['middleware' => ['api']], function () {
diff --git a/src/ShopifyApp/resources/views/billing/error.blade.php b/src/ShopifyApp/resources/views/billing/error.blade.php
new file mode 100644
index 00000000..d1fe38f5
--- /dev/null
+++ b/src/ShopifyApp/resources/views/billing/error.blade.php
@@ -0,0 +1,11 @@
+@extends('shopify-app::layouts.error')
+
+@section('content')
+
+
+
Oops!
+
{{ $message }}
+
+
+
+@endsection
diff --git a/src/ShopifyApp/resources/views/errors/403.blade.php b/src/ShopifyApp/resources/views/layouts/error.blade.php
similarity index 82%
rename from src/ShopifyApp/resources/views/errors/403.blade.php
rename to src/ShopifyApp/resources/views/layouts/error.blade.php
index 83384844..6e8d3c48 100644
--- a/src/ShopifyApp/resources/views/errors/403.blade.php
+++ b/src/ShopifyApp/resources/views/layouts/error.blade.php
@@ -2,7 +2,7 @@
- Oops! | 403
+ Oops!
@@ -56,12 +56,7 @@
-
-
-
Oops!
-
{{ $exception->getMessage() }}
-
-
+ @yield('content')
diff --git a/tests/Controllers/AuthControllerTest.php b/tests/Controllers/AuthControllerTest.php
index 5da798bf..69a7b98c 100644
--- a/tests/Controllers/AuthControllerTest.php
+++ b/tests/Controllers/AuthControllerTest.php
@@ -19,6 +19,9 @@ public function setUp()
{
parent::setUp();
+ // Stub in our API class
+ config(['shopify-app.api_class' => new ApiStub()]);
+
// HMAC for regular tests
$this->hmac = 'a7448f7c42c9bc025b077ac8b73e7600b6f8012719d21cbeb88db66e5dbbd163';
$this->hmacParams = [
@@ -36,9 +39,6 @@ public function setUp()
'code' => '1234678910',
'timestamp' => '1337178173',
];
-
- // Stub in our API class
- config(['shopify-app.api_class' => new ApiStub()]);
}
public function testLoginTest()
@@ -52,14 +52,18 @@ public function testAuthRedirectsBackToLoginWhenNoShop()
$response = $this->post('/authenticate');
$response->assertStatus(302);
- $this->assertEquals('http://localhost/login', $response->headers->get('location'));
+ $response->assertRedirect('http://localhost/login');
}
public function testAuthRedirectsUserToAuthScreenWhenNoCode()
{
- $this->assertEquals(false, config('session.expire_on_close')); // Default for Laravel
+ // Default for Laravel
+ $this->assertNull(config('session.expire_on_close'));
+ // Run the request
$response = $this->post('/authenticate', ['shop' => 'example.myshopify.com']);
+
+ // Check the view
$response->assertSessionHas('shopify_domain');
$response->assertViewHas('shopDomain', 'example.myshopify.com');
$response->assertViewHas(
@@ -67,24 +71,31 @@ public function testAuthRedirectsUserToAuthScreenWhenNoCode()
'https://example.myshopify.com/admin/oauth/authorize?client_id=&scope=read_products,write_products&redirect_uri=https://localhost/authenticate'
);
- $this->assertEquals(true, config('session.expire_on_close')); // Override in auth for a single request
+ // Override in auth for a single request
+ $this->assertTrue(config('session.expire_on_close'));
}
public function testAuthAcceptsShopWithCodeAndUpdatesTokenForShop()
{
$response = $this->call('get', '/authenticate', $this->hmacParams);
- $shop = Shop::where('shopify_domain', 'example.myshopify.com')->first();
- $this->assertEquals('12345678', $shop->shopify_token); // Previous token was 1234
+ // Previous token was 1234
+ $this->assertEquals(
+ '12345678',
+ Shop::where('shopify_domain', 'example.myshopify.com')->first()->shopify_token
+ );
}
public function testAuthRestoresTrashedShop()
{
+ // Get the shop, confirm its trashed
$shop = Shop::withTrashed()->where('shopify_domain', 'trashed-shop.myshopify.com')->first();
$this->assertTrue($shop->trashed());
+ // Do an auth call
$this->call('get', '/authenticate', $this->hmacTrashedParams);
+ // Shop should now be restored
$shop = $shop->fresh();
$this->assertFalse($shop->trashed());
}
@@ -94,11 +105,12 @@ public function testAuthAcceptsShopWithCodeAndRedirectsToHome()
$response = $this->call('get', '/authenticate', $this->hmacParams);
$response->assertStatus(302);
- $this->assertEquals('http://localhost', $response->headers->get('location'));
+ $response->assertRedirect('http://localhost');
}
public function testAuthAcceptsShopWithCodeAndRedirectsToLoginIfRequestIsInvalid()
{
+ // Make the HMAC invalid
$params = $this->hmacParams;
$params['hmac'] = 'MakeMeInvalid';
@@ -106,23 +118,27 @@ public function testAuthAcceptsShopWithCodeAndRedirectsToLoginIfRequestIsInvalid
$response->assertSessionHas('error');
$response->assertStatus(302);
- $this->assertEquals('http://localhost/login', $response->headers->get('location'));
+ $response->assertRedirect('http://localhost/login');
}
public function testAuthenticateDoesNotFiresJobsWhenNoConfigForThem()
{
+ // Fake the queue
Queue::fake();
$this->call('get', '/authenticate', $this->hmacParams);
+ // No jobs should be pushed when theres no config for them
Queue::assertNotPushed(WebhookInstaller::class);
Queue::assertNotPushed(ScripttagInstaller::class);
}
public function testAuthenticateDoesFiresJobs()
{
+ // Fake the queue
Queue::fake();
+ // Create jobs
config(['shopify-app.webhooks' => [
[
'topic' => 'orders/create',
@@ -137,14 +153,17 @@ public function testAuthenticateDoesFiresJobs()
$this->call('get', '/authenticate', $this->hmacParams);
+ // Jobs should be called
Queue::assertPushed(WebhookInstaller::class);
Queue::assertPushed(ScripttagInstaller::class);
}
public function testAfterAuthenticateFiresInline()
{
+ // Fake the queue
Queue::fake();
+ // Create the jobs
$jobClass = \App\Jobs\AfterAuthenticateJob::class;
config(['shopify-app.after_authenticate_job' => [
'job' => $jobClass,
@@ -155,14 +174,17 @@ public function testAfterAuthenticateFiresInline()
$method->setAccessible(true);
$result = $method->invoke(new AuthController());
- $this->assertEquals(true, $result);
+ // Confirm ran, but not pushed
+ $this->assertTrue($result);
Queue::assertNotPushed($jobClass); // since inline == true
}
public function testAfterAuthenticateFiresDispatched()
{
+ // Fake the queue
Queue::fake();
+ // Create the job
$jobClass = \App\Jobs\AfterAuthenticateJob::class;
config(['shopify-app.after_authenticate_job' => [
'job' => $jobClass,
@@ -173,14 +195,17 @@ public function testAfterAuthenticateFiresDispatched()
$method->setAccessible(true);
$result = $method->invoke(new AuthController());
- $this->assertEquals(true, $result);
+ // Confirm ran, and pushed
+ $this->assertTrue($result);
Queue::assertPushed($jobClass); // since inline == false
}
public function testAfterAuthenticateDoesNotFireForNoConfig()
{
+ // Fake the queue
Queue::fake();
+ // Create the jobs... blank
$jobClass = \App\Jobs\AfterAuthenticateJob::class;
config(['shopify-app.after_authenticate_job' => []]);
@@ -188,7 +213,8 @@ public function testAfterAuthenticateDoesNotFireForNoConfig()
$method->setAccessible(true);
$result = $method->invoke(new AuthController());
- $this->assertEquals(false, $result);
+ // Confirm no run, and not pushed
+ $this->assertFalse($result);
Queue::assertNotPushed($jobClass);
}
@@ -200,11 +226,12 @@ public function testAuthPassesAndRedirectsToReturnUrl()
$response = $this->call('get', '/authenticate', $this->hmacParams);
$response->assertStatus(302);
- $this->assertEquals('http://localhost/orders', $response->headers->get('location'));
+ $response->assertRedirect('http://localhost/orders');
}
public function testReturnToMethod()
{
+ // Set in AuthShop middleware
session(['return_to' => 'http://localhost/orders']);
$method = new ReflectionMethod(AuthController::class, 'returnTo');
diff --git a/tests/Controllers/BillingControllerTest.php b/tests/Controllers/BillingControllerTest.php
index b7b5f569..78d1fc29 100644
--- a/tests/Controllers/BillingControllerTest.php
+++ b/tests/Controllers/BillingControllerTest.php
@@ -5,7 +5,9 @@
use Carbon\Carbon;
use OhMyBrew\ShopifyApp\Controllers\BillingController;
use OhMyBrew\ShopifyApp\Models\Charge;
+use OhMyBrew\ShopifyApp\Models\Plan;
use OhMyBrew\ShopifyApp\Models\Shop;
+use OhMyBrew\ShopifyApp\ShopifyApp;
use OhMyBrew\ShopifyApp\Test\Stubs\ApiStub;
use OhMyBrew\ShopifyApp\Test\TestCase;
use ReflectionMethod;
@@ -19,6 +21,9 @@ public function setUp()
// Stub in our API class
config(['shopify-app.api_class' => new ApiStub()]);
+ // Create the main class
+ $this->shopifyApp = new ShopifyApp($this->app);
+
// Base shop for all tests here
$this->shop = Shop::where('shopify_domain', 'example.myshopify.com')->first();
session(['shopify_domain' => $this->shop->shopify_domain]);
@@ -43,7 +48,7 @@ public function testShopAcceptsBilling()
$oldCharge = $shop->charges()->whereIn('type', [Charge::CHARGE_RECURRING, Charge::CHARGE_ONETIME])->orderBy('created_at', 'desc')->first();
// Run with a new charge
- $response = $this->call('get', '/billing/process', ['charge_id' => $chargeId]);
+ $response = $this->call('get', '/billing/process/1', ['charge_id' => $chargeId]);
// Get the new charge and refresh the old one
$newCharge = $shop->charges()->get()->last();
@@ -56,115 +61,98 @@ public function testShopAcceptsBilling()
public function testShopDeclinesBilling()
{
- $shop = Shop::where('shopify_domain', 'example.myshopify.com')->first();
- $response = $this->call('get', '/billing/process', ['charge_id' => 10292]);
- $lastCharge = $shop->charges()->get()->last();
+ // Make the call and grab the last charge
+ $response = $this->call('get', '/billing/process/1', ['charge_id' => 10292]);
+ $lastCharge = $this->shop->charges()->get()->last();
- $response->assertStatus(403);
+ // Should now match
$this->assertEquals(10292, $lastCharge->charge_id);
$this->assertEquals('declined', $lastCharge->status);
- $this->assertEquals(
- 'It seems you have declined the billing charge for this application',
- $response->exception->getMessage()
- );
+ $response->assertViewHas('message', 'It seems you have declined the billing charge for this application');
}
- public function testReturnsBasePlanDetails()
+ public function testReturnOnInstallFlaggedPlan()
{
$controller = new BillingController();
- $method = new ReflectionMethod(BillingController::class, 'planDetails');
+ $method = new ReflectionMethod(BillingController::class, 'getPlan');
$method->setAccessible(true);
// Based on default config
- $this->assertEquals(
- [
- 'name' => config('shopify-app.billing_plan'),
- 'price' => config('shopify-app.billing_price'),
- 'test' => config('shopify-app.billing_test'),
- 'trial_days' => config('shopify-app.billing_trial_days'),
- 'return_url' => url(config('shopify-app.billing_redirect')),
- ],
- $method->invoke($controller, $this->shop)
- );
+ $this->assertEquals(Plan::find(1), $method->invoke($controller, null));
}
- public function testReturnsBasePlanDetailsWithUsage()
+ public function testReturnPlanPassedToController()
{
- config(['shopify-app.billing_capped_amount' => 100.00]);
- config(['shopify-app.billing_terms' => '$1 for 100 emails.']);
-
$controller = new BillingController();
- $method = new ReflectionMethod(BillingController::class, 'planDetails');
+ $method = new ReflectionMethod(BillingController::class, 'getPlan');
$method->setAccessible(true);
// Based on default config
- $this->assertEquals(
- [
- 'name' => config('shopify-app.billing_plan'),
- 'price' => config('shopify-app.billing_price'),
- 'test' => config('shopify-app.billing_test'),
- 'trial_days' => config('shopify-app.billing_trial_days'),
- 'capped_amount' => config('shopify-app.billing_capped_amount'),
- 'terms' => config('shopify-app.billing_terms'),
- 'return_url' => url(config('shopify-app.billing_redirect')),
- ],
- $method->invoke($controller, $this->shop)
- );
+ $this->assertEquals(Plan::find(2), $method->invoke($controller, 2));
}
- public function testReturnsBasePlanDetailsChangedByCancelledCharge()
+ public function testReturnsLastChargeForShop()
{
- $shop = new Shop();
- $shop->shopify_domain = 'test-cancelled-shop.myshopify.com';
- $shop->save();
-
- $charge = new Charge();
- $charge->charge_id = 267921978;
- $charge->test = false;
- $charge->name = 'Base Plan Cancelled';
- $charge->status = 'cancelled';
- $charge->type = 1;
- $charge->price = 25.00;
- $charge->trial_days = 7;
- $charge->trial_ends_on = Carbon::today()->addWeeks(1)->format('Y-m-d');
- $charge->cancelled_on = Carbon::today()->addDays(2)->format('Y-m-d');
- $charge->shop_id = $shop->id;
- $charge->save();
-
$controller = new BillingController();
- $method = new ReflectionMethod(BillingController::class, 'planDetails');
+ $method = new ReflectionMethod(BillingController::class, 'getLastCharge');
$method->setAccessible(true);
// Based on default config
- $this->assertEquals(
- [
- 'name' => config('shopify-app.billing_plan'),
- 'price' => config('shopify-app.billing_price'),
- 'test' => config('shopify-app.billing_test'),
- 'trial_days' => 5,
- 'return_url' => url(config('shopify-app.billing_redirect')),
- ],
- $method->invoke($controller, $shop)
- );
+ $this->assertInstanceOf(Charge::class, $method->invoke($controller, $this->shop));
}
- public function testReturnsBaseChargeType()
+ public function testUsageChargeSuccessWithRedirect()
{
- $controller = new BillingController();
- $method = new ReflectionMethod(BillingController::class, 'chargeType');
- $method->setAccessible(true);
+ // Create a new charge for the shop to make a usage charge against
+ $charge = new Charge();
+ $charge->charge_id = 12939009;
+ $charge->name = 'Base Plan';
+ $charge->type = Charge::CHARGE_RECURRING;
+ $charge->price = 25.00;
+ $charge->shop_id = $this->shop->id;
+ $charge->plan_id = Plan::find(1)->id;
+ $charge->created_at = Carbon::now()->addMinutes(5);
+ $charge->save();
- // Based on default config
- $this->assertEquals(config('shopify-app.billing_type'), $method->invoke($controller));
+ // Setup the data for the usage charge and the signature for it
+ $data = ['description' => 'One email', 'price' => 1.00, 'redirect' => 'https://localhost/usage-success'];
+ $signature = $this->shopifyApp->createHmac(['data' => $data, 'buildQuery' => true]);
+
+ $response = $this->call('post', '/billing/usage-charge', array_merge($data, ['signature' => $signature]));
+ $lastCharge = $this->shop->charges()->get()->last();
+
+ $response->assertStatus(302);
+ $response->assertRedirect($data['redirect']);
+ $this->assertEquals(Charge::CHARGE_USAGE, $lastCharge->type);
+ $this->assertEquals($data['description'], $lastCharge->description);
+ $this->assertEquals($data['price'], $lastCharge->price);
}
- public function testReturnsLastChargeForShop()
+ public function testUsageChargeSuccessWithNoRedirect()
{
- $controller = new BillingController();
- $method = new ReflectionMethod(BillingController::class, 'getLastCharge');
- $method->setAccessible(true);
+ // Create a new charge for the shop to make a usage charge against
+ $charge = new Charge();
+ $charge->charge_id = 21828118;
+ $charge->name = 'Base Plan';
+ $charge->type = Charge::CHARGE_RECURRING;
+ $charge->price = 25.00;
+ $charge->shop_id = $this->shop->id;
+ $charge->plan_id = Plan::find(1)->id;
+ $charge->created_at = Carbon::now()->addMinutes(5);
+ $charge->save();
- // Based on default config
- $this->assertInstanceOf(Charge::class, $method->invoke($controller, $this->shop));
+ // Setup the data for the usage charge and the signature for it
+ $data = ['description' => 'One email', 'price' => 1.00];
+ $signature = $this->shopifyApp->createHmac(['data' => $data, 'buildQuery' => true]);
+
+ $response = $this->call('post', '/billing/usage-charge', array_merge($data, ['signature' => $signature]));
+ $lastCharge = $this->shop->charges()->get()->last();
+
+ $response->assertStatus(302);
+ $response->assertRedirect('http://localhost');
+ $response->assertSessionHas('success');
+ $this->assertEquals(Charge::CHARGE_USAGE, $lastCharge->type);
+ $this->assertEquals($data['description'], $lastCharge->description);
+ $this->assertEquals($data['price'], $lastCharge->price);
}
}
diff --git a/tests/Controllers/HomeControllerTest.php b/tests/Controllers/HomeControllerTest.php
index 89ccd25d..34815577 100644
--- a/tests/Controllers/HomeControllerTest.php
+++ b/tests/Controllers/HomeControllerTest.php
@@ -13,37 +13,43 @@ public function setUp()
// Stub in our API class
config(['shopify-app.api_class' => new ApiStub()]);
+
+ // Shop for all tests
+ session(['shopify_domain' => 'example.myshopify.com']);
}
public function testNoShopSessionShouldRedirectToAuthenticate()
{
+ // Kill the session
+ session()->forget('shopify_domain');
+
$response = $this->call('get', '/', ['shop' => 'example.myshopify.com']);
- $this->assertEquals(true, strpos($response->content(), 'Redirecting to http://localhost/authenticate') !== false);
+ $this->assertTrue(strpos($response->content(), 'Redirecting to http://localhost/authenticate') !== false);
}
public function testWithMismatchedShopsShouldRedirectToAuthenticate()
{
- session(['shopify_domain' => 'example.myshopify.com']);
$response = $this->call('get', '/', ['shop' => 'example-different-shop.myshopify.com']);
- $this->assertEquals(true, strpos($response->content(), 'Redirecting to http://localhost/authenticate') !== false);
+ $this->assertTrue(strpos($response->content(), 'Redirecting to http://localhost/authenticate') !== false);
}
public function testShopWithSessionShouldLoad()
{
- session(['shopify_domain' => 'example.myshopify.com']);
$response = $this->get('/');
+
$response->assertStatus(200);
- $this->assertEquals(true, strpos($response->content(), "apiKey: ''") !== false);
- $this->assertEquals(true, strpos($response->content(), "shopOrigin: 'https://example.myshopify.com'") !== false);
+ $this->assertTrue(strpos($response->content(), "apiKey: ''") !== false);
+ $this->assertTrue(strpos($response->content(), "shopOrigin: 'https://example.myshopify.com'") !== false);
}
public function testShopWithSessionAndDisabledEsdkShouldLoad()
{
- session(['shopify_domain' => 'example.myshopify.com']);
+ // Tuen off ESDK
config(['shopify-app.esdk_enabled' => false]);
$response = $this->get('/');
+
$response->assertStatus(200);
- $this->assertEquals(false, strpos($response->content(), 'ShopifyApp.init'));
+ $this->assertFalse(strpos($response->content(), 'ShopifyApp.init'));
}
}
diff --git a/tests/Controllers/WebhookControllerTest.php b/tests/Controllers/WebhookControllerTest.php
index 049bc03d..ccd587cd 100644
--- a/tests/Controllers/WebhookControllerTest.php
+++ b/tests/Controllers/WebhookControllerTest.php
@@ -15,6 +15,7 @@ public function setUp()
{
parent::setUp();
+ // Mock headers that match Shopify
$this->headers = [
'HTTP_CONTENT_TYPE' => 'application/json',
'HTTP_X_SHOPIFY_SHOP_DOMAIN' => 'example.myshopify.com',
@@ -24,8 +25,10 @@ public function setUp()
public function testShouldReturn201ResponseOnSuccess()
{
+ // Fake the queue
Queue::fake();
+ // Create a webhook call and pass in our own headers and data
$response = $this->call(
'post',
'/webhook/orders-create',
@@ -35,13 +38,15 @@ public function testShouldReturn201ResponseOnSuccess()
$this->headers,
file_get_contents(__DIR__.'/../fixtures/webhook.json')
);
- $response->assertStatus(201);
+ // Check it was created and job was pushed
+ $response->assertStatus(201);
Queue::assertPushed(\App\Jobs\OrdersCreateJob::class);
}
public function testShouldReturnErrorResponseOnFailure()
{
+ // Create a webhook call and pass in our own headers and data
$response = $this->call(
'post',
'/webhook/products-create',
@@ -51,6 +56,8 @@ public function testShouldReturnErrorResponseOnFailure()
$this->headers,
file_get_contents(__DIR__.'/../fixtures/webhook.json')
);
+
+ // Check it contains error and exception matches
$response->assertStatus(500);
$this->assertEquals('Missing webhook job: \App\Jobs\ProductsCreateJob', $response->exception->getMessage());
}
@@ -61,12 +68,14 @@ public function testShouldCaseTypeToClass()
$method = new ReflectionMethod(WebhookController::class, 'getJobClassFromType');
$method->setAccessible(true);
+ // Map URL path to job
$types = [
'orders-create' => 'OrdersCreateJob',
'super-duper-order' => 'SuperDuperOrderJob',
'order' => 'OrderJob',
];
+ // Confirm mapping
foreach ($types as $type => $className) {
$this->assertEquals("\\App\\Jobs\\$className", $method->invoke($controller, $type));
}
@@ -74,8 +83,10 @@ public function testShouldCaseTypeToClass()
public function testWebhookShouldRecieveData()
{
+ // Fake the queue
Queue::fake();
+ // Create a webhook call and pass in our own headers and data
$response = $this->call(
'post',
'/webhook/orders-create',
@@ -85,8 +96,9 @@ public function testWebhookShouldRecieveData()
$this->headers,
file_get_contents(__DIR__.'/../fixtures/webhook.json')
);
- $response->assertStatus(201);
+ // Check it was created, and job was ushed with matching data from fixture
+ $response->assertStatus(201);
Queue::assertPushed(\App\Jobs\OrdersCreateJob::class, function ($job) {
return $job->shopDomain === 'example.myshopify.com'
&& $job->data instanceof \stdClass
diff --git a/tests/Jobs/AppUninstalledJobTest.php b/tests/Jobs/AppUninstalledJobTest.php
index 4d764394..41e129a1 100644
--- a/tests/Jobs/AppUninstalledJobTest.php
+++ b/tests/Jobs/AppUninstalledJobTest.php
@@ -20,6 +20,7 @@ public function setup()
$this->shop->shopify_domain = 'example-isolated.myshopify.com';
$this->shop->save();
+ // Get the data
$this->data = json_decode(file_get_contents(__DIR__.'/../fixtures/app_uninstalled.json'));
}
@@ -84,6 +85,6 @@ public function testJobSoftDeletesShopAndCharges()
public function testJobDoesNothingForUnknownShop()
{
$job = new AppUninstalledJob('unknown-shop.myshopify.com', null);
- $this->assertEquals(false, $job->handle());
+ $this->assertFalse($job->handle());
}
}
diff --git a/tests/Libraries/BillingPlanTest.php b/tests/Libraries/BillingPlanTest.php
index 9bfd4257..df2f90d9 100644
--- a/tests/Libraries/BillingPlanTest.php
+++ b/tests/Libraries/BillingPlanTest.php
@@ -3,6 +3,7 @@
namespace OhMyBrew\ShopifyApp\Test\Libraries;
use OhMyBrew\ShopifyApp\Libraries\BillingPlan;
+use OhMyBrew\ShopifyApp\Models\Plan;
use OhMyBrew\ShopifyApp\Models\Shop;
use OhMyBrew\ShopifyApp\Test\Stubs\ApiStub;
use OhMyBrew\ShopifyApp\Test\TestCase;
@@ -16,54 +17,55 @@ public function setUp()
// Stub in our API class
config(['shopify-app.api_class' => new ApiStub()]);
- // Base shop and plan
+ // Base shop to use
$this->shop = Shop::find(1);
- $this->plan = [
- 'name' => 'Basic Plan',
- 'price' => 3.00,
- 'trial_days' => 0,
- 'return_url' => 'http://example.com/',
- ];
}
public function testShouldReturnConfirmationUrl()
{
- $url = (new BillingPlan($this->shop))->setDetails($this->plan)->getConfirmationUrl();
-
$this->assertEquals(
'https://example.myshopify.com/admin/charges/1029266947/confirm_recurring_application_charge?signature=BAhpBANeWT0%3D--64de8739eb1e63a8f848382bb757b20343eb414f',
- $url
+ (new BillingPlan($this->shop, Plan::find(1)))->getConfirmationUrl()
);
}
public function testShouldReturnConfirmationUrlWhenUsageIsEnabled()
{
- $plan = array_merge($this->plan, [
- 'capped_amount' => 100.00,
- 'terms' => '$1 for 500 emails',
- ]);
- $url = (new BillingPlan($this->shop))->setDetails($plan)->getConfirmationUrl();
-
$this->assertEquals(
- 'https://example.myshopify.com/admin/charges/1029266947/confirm_recurring_application_charge?signature=BAhpBANeWT0%3D--64de8739eb1e63a8f848382bb757b20343eb414f',
- $url
+ 'https://example.myshopify.com/admin/charges/1017262355/confirm_application_charge?signature=BAhpBBMxojw%3D--1139a82a3433b1a6771786e03f02300440e11883',
+ (new BillingPlan($this->shop, Plan::find(3)))->getConfirmationUrl()
);
}
/**
- * @expectedException \Exception
- * @expectedExceptionMessage Plan details are missing for confirmation URL request.
+ * @expectedException \ArgumentCountError
*/
- public function testShouldNotReturnConfirmationUrlAndThrowException()
+ public function testShouldThrowExceptionForMissingPlan()
{
- (new BillingPlan($this->shop))->getConfirmationUrl();
+ new BillingPlan($this->shop);
+ }
+
+ public function testShouldReturnChargeParams()
+ {
+ $this->assertEquals(
+ [
+ 'test' => false,
+ 'trial_days' => '7',
+ 'name' => 'Capped Plan',
+ 'price' => '5',
+ 'return_url' => secure_url(config('shopify-app.billing_redirect'), ['plan_id' => 4]),
+ 'capped_amount' => '100',
+ 'terms' => '$1 for 500 emails',
+ ],
+ (new BillingPlan($this->shop, Plan::find(4)))->getChargeParams()
+ );
}
public function testShouldActivatePlan()
{
- $response = (new BillingPlan($this->shop))->setChargeId(1029266947)->activate();
+ $response = (new BillingPlan($this->shop, Plan::find(1)))->setChargeId(1029266947)->activate();
- $this->assertEquals(true, is_object($response));
+ $this->assertTrue(is_object($response));
$this->assertEquals('active', $response->status);
}
@@ -73,14 +75,14 @@ public function testShouldActivatePlan()
*/
public function testShouldNotActivatePlanAndThrowException()
{
- (new BillingPlan($this->shop))->activate();
+ (new BillingPlan($this->shop, Plan::find(1)))->activate();
}
public function testShouldGetChargeDetails()
{
- $response = (new BillingPlan($this->shop))->setChargeId(1029266947)->getCharge();
+ $response = (new BillingPlan($this->shop, Plan::find(1)))->setChargeId(1029266947)->getCharge();
- $this->assertEquals(true, is_object($response));
+ $this->assertTrue(is_object($response));
$this->assertEquals('accepted', $response->status);
}
@@ -90,6 +92,6 @@ public function testShouldGetChargeDetails()
*/
public function testShouldNotGetChargeDetailsAndThrowException()
{
- (new BillingPlan($this->shop))->getCharge();
+ (new BillingPlan($this->shop, Plan::find(1)))->getCharge();
}
}
diff --git a/tests/Middleware/AuthProxyMiddlewareTest.php b/tests/Middleware/AuthProxyMiddlewareTest.php
index f275a3f1..036dd36a 100644
--- a/tests/Middleware/AuthProxyMiddlewareTest.php
+++ b/tests/Middleware/AuthProxyMiddlewareTest.php
@@ -32,6 +32,7 @@ public function setUp()
*/
public function testDenysForMissingShop()
{
+ // Remove shop from params
$query = $this->queryParams;
unset($query['shop']);
Input::merge($query);
@@ -42,7 +43,7 @@ public function testDenysForMissingShop()
$called = true;
});
- $this->assertEquals(false, $called);
+ $this->assertFalse($called);
}
public function testRuns()
@@ -50,7 +51,7 @@ public function testRuns()
Input::merge($this->queryParams);
// Confirm no shop
- $this->assertEquals(null, session('shopify_domain'));
+ $this->assertNull(session('shopify_domain'));
$called = false;
(new AuthProxy())->handle(request(), function ($request) use (&$called) {
@@ -66,7 +67,7 @@ public function testRuns()
});
// Confirm full run
- $this->assertEquals(true, $called);
+ $this->assertTrue($called);
}
/**
@@ -75,6 +76,7 @@ public function testRuns()
*/
public function testDoesNotRunForInvalidSignature()
{
+ // Make the signature invalid
$query = $this->queryParams;
$query['oops'] = 'i-did-it-again';
Input::merge($query);
@@ -85,6 +87,6 @@ public function testDoesNotRunForInvalidSignature()
$called = true;
});
- $this->assertEquals(false, $called);
+ $this->assertFalse($called);
}
}
diff --git a/tests/Middleware/AuthShopMiddlewareTest.php b/tests/Middleware/AuthShopMiddlewareTest.php
index 25b7daea..67adfe05 100644
--- a/tests/Middleware/AuthShopMiddlewareTest.php
+++ b/tests/Middleware/AuthShopMiddlewareTest.php
@@ -17,7 +17,7 @@ public function testShopHasNoAccessShouldAbort()
});
$this->assertFalse($called);
- $this->assertEquals(true, strpos($result, 'Redirecting to http://localhost/authenticate') !== false);
+ $this->assertTrue(strpos($result, 'Redirecting to http://localhost/authenticate') !== false);
}
public function testShopHasWithAccessShouldPassMiddleware()
@@ -31,7 +31,7 @@ public function testShopHasWithAccessShouldPassMiddleware()
$called = true;
});
- $this->assertEquals(true, $called);
+ $this->assertTrue($called);
}
public function testShopWithNoTokenShouldNotPassMiddleware()
@@ -46,7 +46,7 @@ public function testShopWithNoTokenShouldNotPassMiddleware()
});
$this->assertFalse($called);
- $this->assertEquals(true, strpos($result, 'Redirecting to http://localhost/authenticate') !== false);
+ $this->assertTrue(strpos($result, 'Redirecting to http://localhost/authenticate') !== false);
}
public function testShopTrashedShouldNotPassMiddleware()
@@ -61,7 +61,7 @@ public function testShopTrashedShouldNotPassMiddleware()
});
$this->assertFalse($called);
- $this->assertEquals(true, strpos($result, 'Redirecting to http://localhost/authenticate') !== false);
+ $this->assertTrue(strpos($result, 'Redirecting to http://localhost/authenticate') !== false);
}
public function testShopsWhichDoNotMatchShouldKillSessionAndDirectToReAuthenticate()
@@ -129,6 +129,6 @@ public function testShouldSaveReturnUrl()
$this->assertFalse($called);
$this->assertEquals('http://localhost/orders', session('return_to'));
- $this->assertEquals(true, strpos($result, 'Redirecting to http://localhost/authenticate') !== false);
+ $this->assertTrue(strpos($result, 'Redirecting to http://localhost/authenticate') !== false);
}
}
diff --git a/tests/Middleware/AuthWebhookMiddlewareTest.php b/tests/Middleware/AuthWebhookMiddlewareTest.php
index e8fab081..efa00f7c 100644
--- a/tests/Middleware/AuthWebhookMiddlewareTest.php
+++ b/tests/Middleware/AuthWebhookMiddlewareTest.php
@@ -30,13 +30,16 @@ public function testDenysForMissingHmacHeader()
{
request()->header('x-shopify-shop-domain', 'example.myshopify.com');
(new AuthWebhook())->handle(request(), function ($request) {
+ // ...
});
}
public function testRuns()
{
+ // Fake the queue
Queue::fake();
+ // Run the call with our owm mocked Shopify headers and data
$response = $this->call(
'post',
'/webhook/orders-create',
@@ -50,13 +53,16 @@ public function testRuns()
],
file_get_contents(__DIR__.'/../fixtures/webhook.json')
);
+
$response->assertStatus(201);
}
public function testInvalidHmacWontRun()
{
+ // Fake the data
Queue::fake();
+ // Run the call with our owm mocked Shopify headers and data
$response = $this->call(
'post',
'/webhook/orders-create',
@@ -70,6 +76,7 @@ public function testInvalidHmacWontRun()
],
file_get_contents(__DIR__.'/../fixtures/webhook.json').'invalid'
);
+
$response->assertStatus(401);
}
}
diff --git a/tests/Middleware/BillableMiddlewareTest.php b/tests/Middleware/BillableMiddlewareTest.php
index d723eb0d..892025d9 100644
--- a/tests/Middleware/BillableMiddlewareTest.php
+++ b/tests/Middleware/BillableMiddlewareTest.php
@@ -20,30 +20,29 @@ public function testEnabledBillingWithUnpaidShop()
});
$this->assertFalse($called);
- $this->assertEquals(true, strpos($result, 'Redirecting to http://localhost/billing') !== false);
+ $this->assertTrue(strpos($result, 'Redirecting to http://localhost/billing') !== false);
}
- public function testEnabledBillingWithShopWhoDeclinedCharges()
+ public function testEnabledBillingWithPaidShop()
{
// Enable billing and set a shop
config(['shopify-app.billing_enabled' => true]);
- session(['shopify_domain' => 'trashed-shop.myshopify.com']);
+ session(['shopify_domain' => 'example.myshopify.com']);
$called = false;
$result = (new Billable())->handle(request(), function ($request) use (&$called) {
- // Should never be called
+ // Should be called
$called = true;
});
- $this->assertFalse($called);
- $this->assertEquals(true, strpos($result, 'Redirecting to http://localhost/billing') !== false);
+ $this->assertTrue($called);
}
- public function testEnabledBillingWithPaidShop()
+ public function testEnabledBillingWithGrandfatheredShop()
{
// Enable billing and set a shop
config(['shopify-app.billing_enabled' => true]);
- session(['shopify_domain' => 'example.myshopify.com']);
+ session(['shopify_domain' => 'grandfathered.myshopify.com']);
$called = false;
$result = (new Billable())->handle(request(), function ($request) use (&$called) {
@@ -54,11 +53,11 @@ public function testEnabledBillingWithPaidShop()
$this->assertTrue($called);
}
- public function testEnabledBillingWithGrandfatheredShop()
+ public function testEnabledBillingWithFreemiumShop()
{
// Enable billing and set a shop
config(['shopify-app.billing_enabled' => true]);
- session(['shopify_domain' => 'grandfathered.myshopify.com']);
+ session(['shopify_domain' => 'freemium-shop.myshopify.com']);
$called = false;
$result = (new Billable())->handle(request(), function ($request) use (&$called) {
diff --git a/tests/Models/ChargeModelTest.php b/tests/Models/ChargeModelTest.php
index 58602e85..157f8955 100644
--- a/tests/Models/ChargeModelTest.php
+++ b/tests/Models/ChargeModelTest.php
@@ -3,6 +3,7 @@
namespace OhMyBrew\ShopifyApp\Test\Models;
use OhMyBrew\ShopifyApp\Models\Charge;
+use OhMyBrew\ShopifyApp\Models\Plan;
use OhMyBrew\ShopifyApp\Models\Shop;
use OhMyBrew\ShopifyApp\Test\Stubs\ApiStub;
use OhMyBrew\ShopifyApp\Test\TestCase;
@@ -11,23 +12,22 @@ class ChargeModelTest extends TestCase
{
public function testBelongsToShop()
{
- $this->assertInstanceOf(
- Shop::class,
- Charge::find(1)->shop
- );
+ $this->assertInstanceOf(Shop::class, Charge::find(1)->shop);
}
public function testChargeImplementsType()
{
- $this->assertEquals(
- Charge::CHARGE_RECURRING,
- Charge::find(1)->type
- );
+ $this->assertEquals(Charge::CHARGE_RECURRING, Charge::find(1)->type);
+ }
+
+ public function testBelongsToPlan()
+ {
+ $this->assertInstanceOf(Plan::class, Charge::find(1)->plan);
}
public function testIsTest()
{
- $this->assertEquals(true, Charge::find(1)->isTest());
+ $this->assertTrue(Charge::find(1)->isTest());
}
public function testIsType()
diff --git a/tests/Models/PlanModelTest.php b/tests/Models/PlanModelTest.php
new file mode 100644
index 00000000..673688d4
--- /dev/null
+++ b/tests/Models/PlanModelTest.php
@@ -0,0 +1,44 @@
+charges;
+
+ $this->assertInstanceOf(Collection::class, $charges);
+ $this->assertTrue(count($charges) > 0);
+ }
+
+ public function testReturnsTypeAsString()
+ {
+ $plan = Plan::find(1);
+
+ $this->assertEquals('recurring_application_charge', $plan->typeAsString());
+ $this->assertEquals('recurring_application_charges', $plan->typeAsString(true));
+ }
+
+ public function testPlanHasTrial()
+ {
+ $this->assertFalse(Plan::find(2)->hasTrial());
+ $this->assertTrue(Plan::find(1)->hasTrial());
+ }
+
+ public function testPlanOnInstallFlag()
+ {
+ $this->assertFalse(Plan::find(2)->isOnInstall());
+ $this->assertTrue(Plan::find(1)->isOnInstall());
+ }
+
+ public function testPlanIsTest()
+ {
+ $this->assertTrue(Plan::find(3)->isTest());
+ $this->assertFalse(Plan::find(1)->isTest());
+ }
+}
diff --git a/tests/Models/ShopModelTest.php b/tests/Models/ShopModelTest.php
index 40ad1282..203d938e 100644
--- a/tests/Models/ShopModelTest.php
+++ b/tests/Models/ShopModelTest.php
@@ -2,6 +2,7 @@
namespace OhMyBrew\ShopifyApp\Test\Models;
+use OhMyBrew\ShopifyApp\Models\Plan;
use OhMyBrew\ShopifyApp\Models\Shop;
use OhMyBrew\ShopifyApp\Test\TestCase;
@@ -49,8 +50,8 @@ public function testShopShouldReturnGrandfatheredState()
$shop = Shop::where('shopify_domain', 'grandfathered.myshopify.com')->first();
$shop_2 = Shop::where('shopify_domain', 'example.myshopify.com')->first();
- $this->assertEquals(true, $shop->isGrandfathered());
- $this->assertEquals(false, $shop_2->isGrandfathered());
+ $this->assertTrue($shop->isGrandfathered());
+ $this->assertFalse($shop_2->isGrandfathered());
}
public function testShopCanSoftDeleteAndBeRestored()
@@ -77,7 +78,23 @@ public function testShouldReturnBoolForChargesApplied()
$shop = Shop::where('shopify_domain', 'grandfathered.myshopify.com')->first();
$shop_2 = Shop::where('shopify_domain', 'example.myshopify.com')->first();
- $this->assertEquals(false, $shop->hasCharges());
- $this->assertEquals(true, $shop_2->hasCharges());
+ $this->assertFalse($shop->hasCharges());
+ $this->assertTrue($shop_2->hasCharges());
+ }
+
+ public function testShopReturnsPlan()
+ {
+ $this->assertInstanceOf(Plan::class, Shop::find(1)->plan);
+ }
+
+ public function testShopReturnsNoPlan()
+ {
+ $this->assertEquals(null, Shop::find(5)->plan);
+ }
+
+ public function testShopIsFreemiumAndNotFreemium()
+ {
+ $this->assertTrue(Shop::find(5)->isFreemium());
+ $this->assertFalse(Shop::find(1)->isFreemium());
}
}
diff --git a/tests/Observers/ShopObserverTest.php b/tests/Observers/ShopObserverTest.php
index f81d2edc..2d523e73 100644
--- a/tests/Observers/ShopObserverTest.php
+++ b/tests/Observers/ShopObserverTest.php
@@ -19,4 +19,15 @@ public function testObserverAddsNamespace()
$this->assertEquals('shopify-test-namespace', $shop->namespace);
}
+
+ public function testObserverSetsFreemiumFlag()
+ {
+ config(['shopify-app.billing_freemium_enabled' => true]);
+
+ $shop = new Shop();
+ $shop->shopify_domain = 'observer-freemium.myshopify.com';
+ $shop->save();
+
+ $this->assertTrue($shop->isFreemium());
+ }
}
diff --git a/tests/ShopifyAppTest.php b/tests/ShopifyAppTest.php
index aaaee770..039d32b6 100644
--- a/tests/ShopifyAppTest.php
+++ b/tests/ShopifyAppTest.php
@@ -36,7 +36,8 @@ public function testShopWithSession()
public function testCreatesNewShopWithSessionIfItDoesNotExist()
{
session(['shopify_domain' => 'example-nonexistant.myshopify.com']);
- $this->assertEquals(null, Shop::where('shopify_domain', 'example-nonexistant.myshopify.com')->first());
+
+ $this->assertNull(Shop::where('shopify_domain', 'example-nonexistant.myshopify.com')->first());
$this->shopifyApp->shop();
@@ -67,7 +68,7 @@ public function testShopSanitize()
// Test for empty shops
foreach ($domains_3 as $domain) {
- $this->assertEquals(null, $this->shopifyApp->sanitizeShopDomain($domain));
+ $this->assertNull($this->shopifyApp->sanitizeShopDomain($domain));
}
}
@@ -76,7 +77,8 @@ public function testShouldUseDefaultModel()
session(['shopify_domain' => 'example.myshopify.com']);
$shop = $this->shopifyApp->shop();
- $this->assertEquals('OhMyBrew\ShopifyApp\Models\Shop', get_class($shop));
+
+ $this->assertEquals(\OhMyBrew\ShopifyApp\Models\Shop::class, get_class($shop));
}
public function testShouldAllowForModelOverride()
@@ -85,7 +87,36 @@ public function testShouldAllowForModelOverride()
config(['shopify-app.shop_model' => 'OhMyBrew\ShopifyApp\Test\Stubs\ShopModelStub']);
$shop = $this->shopifyApp->shop();
- $this->assertEquals('OhMyBrew\ShopifyApp\Test\Stubs\ShopModelStub', get_class($shop));
+
+ $this->assertEquals(\OhMyBrew\ShopifyApp\Test\Stubs\ShopModelStub::class, get_class($shop));
$this->assertEquals('hello', $shop->hello());
}
+
+ public function testHmacCreator()
+ {
+ // Set the secret to use for HMAC creations
+ $secret = 'hello';
+ config(['shopify-app.api_secret' => $secret]);
+
+ // Raw data
+ $data = 'one-two-three';
+ $this->assertEquals(
+ hash_hmac('sha256', $data, $secret, true),
+ $this->shopifyApp->createHmac(['data' => $data, 'raw' => true])
+ );
+
+ // Raw data encoded
+ $data = 'one-two-three';
+ $this->assertEquals(
+ base64_encode(hash_hmac('sha256', $data, $secret, true)),
+ $this->shopifyApp->createHmac(['data' => $data, 'raw' => true, 'encode' => true])
+ );
+
+ // Query build (sorts array and builds query string)
+ $data = ['one' => 1, 'two' => 2, 'three' => 3];
+ $this->assertEquals(
+ hash_hmac('sha256', 'one=1three=3two=2', $secret, false),
+ $this->shopifyApp->createHmac(['data' => $data, 'buildQuery' => true])
+ );
+ }
}
diff --git a/tests/TestCase.php b/tests/TestCase.php
index 8a622508..976d54f3 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -4,6 +4,7 @@
use Carbon\Carbon;
use OhMyBrew\ShopifyApp\Models\Charge;
+use OhMyBrew\ShopifyApp\Models\Plan;
use OhMyBrew\ShopifyApp\Models\Shop;
use OhMyBrew\ShopifyApp\ShopifyAppProvider;
use Orchestra\Database\ConsoleServiceProvider;
@@ -62,6 +63,7 @@ protected function setupDatabase($app)
protected function seedDatabase()
{
+ $this->createPlans();
$this->createShops();
$this->createCharges();
}
@@ -73,6 +75,7 @@ protected function createShops()
[
'shopify_domain' => 'example.myshopify.com',
'shopify_token' => '1234',
+ 'plan_id' => 1,
],
// Non-paid shop, grandfathered
@@ -92,6 +95,12 @@ protected function createShops()
[
'shopify_domain' => 'no-token-shop.myshopify.com',
],
+
+ // Shop on freemium
+ [
+ 'shopify_domain' => 'freemium-shop.myshopify.com',
+ 'freemium' => true,
+ ],
];
// Build the shops
@@ -124,6 +133,7 @@ public function createCharges()
'trial_days' => 7,
'trial_ends_on' => Carbon::createFromDate(2018, 6, 3, 'UTC')->addWeeks(1)->format('Y-m-d'),
'shop_id' => Shop::where('shopify_domain', 'example.myshopify.com')->first()->id,
+ 'plan_id' => 1,
],
// Test = false, status = active, trial = 7, active trial = yes
@@ -137,6 +147,7 @@ public function createCharges()
'trial_days' => 7,
'trial_ends_on' => Carbon::today()->addDays(2)->format('Y-m-d'),
'shop_id' => Shop::where('shopify_domain', 'example.myshopify.com')->first()->id,
+ 'plan_id' => 1,
],
// Test = false, status = active, trial = 7, active trial = no
@@ -150,6 +161,7 @@ public function createCharges()
'trial_days' => 7,
'trial_ends_on' => Carbon::today()->subWeeks(4)->format('Y-m-d'),
'shop_id' => Shop::where('shopify_domain', 'example.myshopify.com')->first()->id,
+ 'plan_id' => 1,
],
// Test = false, status = active, trial = 0
@@ -162,6 +174,7 @@ public function createCharges()
'price' => 25.00,
'trial_days' => 0,
'shop_id' => Shop::where('shopify_domain', 'example.myshopify.com')->first()->id,
+ 'plan_id' => 2,
],
// Test = false, status = declined, trial = 7, active trial = true
@@ -173,6 +186,7 @@ public function createCharges()
'type' => 1,
'price' => 25.00,
'shop_id' => Shop::where('shopify_domain', 'no-token-shop.myshopify.com')->first()->id,
+ 'plan_id' => 1,
],
// Test = false, status = cancelled
@@ -185,6 +199,7 @@ public function createCharges()
'price' => 25.00,
'shop_id' => Shop::where('shopify_domain', 'example.myshopify.com')->first()->id,
'cancelled_on' => Carbon::today()->format('Y-m-d'),
+ 'plan_id' => 1,
],
// Test = false, status = cancelled, trial = 7
@@ -199,6 +214,7 @@ public function createCharges()
'trial_ends_on' => Carbon::today()->addWeeks(1)->format('Y-m-d'),
'cancelled_on' => Carbon::today()->addDays(2)->format('Y-m-d'),
'shop_id' => Shop::withTrashed()->where('shopify_domain', 'trashed-shop.myshopify.com')->first()->id,
+ 'plan_id' => 1,
],
];
@@ -211,4 +227,60 @@ public function createCharges()
$charge->save();
}
}
+
+ public function createPlans()
+ {
+ $plans = [
+ // Basic Plan with Trial
+ [
+ 'type' => 1,
+ 'name' => 'Basic Plan with Trial',
+ 'price' => 5.00,
+ 'trial_days' => 7,
+ 'test' => false,
+ 'on_install' => true,
+ ],
+
+ // Basic Plan with No Trial
+ [
+ 'type' => 1,
+ 'name' => 'Basic Plan with No Trial',
+ 'price' => 5.00,
+ 'trial_days' => 0,
+ 'test' => false,
+ 'on_install' => false,
+ ],
+
+ // Test Plan
+ [
+ 'type' => 2,
+ 'name' => 'Test Plan',
+ 'price' => 5.00,
+ 'trial_days' => 7,
+ 'test' => true,
+ 'on_install' => false,
+ ],
+
+ // Test Plan
+ [
+ 'type' => 1,
+ 'name' => 'Capped Plan',
+ 'price' => 5.00,
+ 'trial_days' => 7,
+ 'test' => false,
+ 'on_install' => false,
+ 'capped_amount' => 100.00,
+ 'terms' => '$1 for 500 emails',
+ ],
+ ];
+
+ // Build the plans
+ foreach ($plans as $planData) {
+ $plan = new Plan();
+ foreach ($planData as $key => $value) {
+ $plan->{$key} = $value;
+ }
+ $plan->save();
+ }
+ }
}
diff --git a/tests/fixtures/14fac79628dbb02150f6bbb072a31620fcc5b445.json b/tests/fixtures/14fac79628dbb02150f6bbb072a31620fcc5b445.json
new file mode 100644
index 00000000..cc0390f5
--- /dev/null
+++ b/tests/fixtures/14fac79628dbb02150f6bbb072a31620fcc5b445.json
@@ -0,0 +1,12 @@
+{
+ "usage_charge": {
+ "id": 1034618208,
+ "description": "One email",
+ "price": "1.00",
+ "created_at": "2018-07-05T13:05:43-04:00",
+ "billing_on": "2018-08-04",
+ "balance_used": 11.0,
+ "balance_remaining": 89.0,
+ "risk_level": 0.08
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/9aeb4b1bdd9bc97203c63c008e766f55e9af3a39.json b/tests/fixtures/9aeb4b1bdd9bc97203c63c008e766f55e9af3a39.json
new file mode 100644
index 00000000..9191cb45
--- /dev/null
+++ b/tests/fixtures/9aeb4b1bdd9bc97203c63c008e766f55e9af3a39.json
@@ -0,0 +1,16 @@
+{
+ "application_charge": {
+ "id": 1017262355,
+ "name": "Basic Plan",
+ "api_client_id": 755357713,
+ "price": "5.00",
+ "status": "pending",
+ "return_url": "http://super-duper.shopifyapps.com/",
+ "test": null,
+ "created_at": "2018-07-05T13:11:28-04:00",
+ "updated_at": "2018-07-05T13:11:28-04:00",
+ "charge_type": null,
+ "decorated_return_url": "http://example.shopifyapps.com/?charge_id=1017262355",
+ "confirmation_url": "https://example.myshopify.com/admin/charges/1017262355/confirm_application_charge?signature=BAhpBBMxojw%3D--1139a82a3433b1a6771786e03f02300440e11883"
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/c9703dd31785be2b8f0a1a4c5916090542b6e11b.json b/tests/fixtures/c9703dd31785be2b8f0a1a4c5916090542b6e11b.json
new file mode 100644
index 00000000..cc0390f5
--- /dev/null
+++ b/tests/fixtures/c9703dd31785be2b8f0a1a4c5916090542b6e11b.json
@@ -0,0 +1,12 @@
+{
+ "usage_charge": {
+ "id": 1034618208,
+ "description": "One email",
+ "price": "1.00",
+ "created_at": "2018-07-05T13:05:43-04:00",
+ "billing_on": "2018-08-04",
+ "balance_used": 11.0,
+ "balance_remaining": 89.0,
+ "risk_level": 0.08
+ }
+}
\ No newline at end of file