Billing through Shopify
An app published on the Shopify App Store has to bill its merchants through a billing solution Shopify provides. The Shopify driver does that: it opens subscriptions at Shopify through the Admin GraphQL API, the merchant approves them in the Shopify admin, and the package learns their state from Shopify's webhooks. Plans, entitlements and the notices about them work as they do on every other driver.
Shopify runs the cycle. It charges the merchant on their Shopify invoice, collects the tax that applies, and pays the app's share to your partner account. So the package issues no invoice, computes no tax and runs no dunning of its own here, and it never sees a payment method.
Setting it up
Select the driver and give it the client secret of your Shopify app, which Shopify signs every webhook with:
BILLING_DRIVER=shopify
BILLING_SHOPIFY_CLIENT_SECRET=your-client-secret
The billing owner is the shop. Point billing.customer.model at the model your app keeps a shop in, and
billing.customer.column at the column that holds its permanent *.myshopify.com domain. That domain is the
owner's customer reference, so a webhook finds the owner by it, and it is the host of every request the driver
makes for the owner.
Your app holds an offline access token for each shop that installed it. Tell the package where it is by
binding ShopifyAccessTokens in a service provider of yours:
use App\Models\Shop;
use Pushery\Billing\Contracts\ShopifyAccessTokens;
$this->app->bind(ShopifyAccessTokens::class, fn (): ShopifyAccessTokens => new class implements ShopifyAccessTokens
{
public function for(string $shop): string
{
$token = Shop::query()->where('domain', $shop)->value('access_token');
return is_string($token) ? $token : throw new RuntimeException("No access token for {$shop}.");
}
});
Subscribe the topics app_subscriptions/update, app_purchases_one_time/update and app/uninstalled to the
package's webhook path, for instance in the shopify.app.toml of your app:
[[webhooks.subscriptions]]
topics = ["app_subscriptions/update", "app_purchases_one_time/update", "app/uninstalled"]
uri = "https://your-app.example/billing/webhook"
Opening a subscription
Checkout::subscribe() opens the subscription at Shopify and returns where the merchant approves it:
use Pushery\Billing\Contracts\Checkout;
$intent = app(Checkout::class)->subscribe($shop, 'pro');
$url = $intent->payload['checkout_url'];
Send the merchant to that page. Inside the Shopify admin your app runs in a frame, and the admin does not show
its confirmation page inside it, so open the page in the top frame, for instance with
window.open(url, '_top'). The account hub's plan screen does this itself when it runs in a frame. Once the
merchant approves, Shopify returns them to billing.checkout.success_url.
The price is the tier's price_display, never a value from the request. Shopify bills every 30 days or every
year, so a tier sold here has the interval month or year, and any other interval is refused. The trial is
the one billing.trial grants, once per owner, and Shopify counts it from the moment the subscription was
created.
Shopify keeps one subscription per installation of an app. A second contract type beside it is refused, and so is a caller reference, because a Shopify subscription carries no data of the app. A coupon code is not applied: Shopify discounts a subscription only as part of its price.
Selling an add-on
OneTimeCharge::purchase() opens a one-time purchase at Shopify for the add-on's price_display and returns
where the merchant approves it, the same way a subscription is opened. Once Shopify reports the purchase active,
the package records it as an add-on purchase, and whatever the add-on grants follows from there, prepaid units
that do not expire among them: the package keeps those itself, and Shopify only collects the purchase. The
declarations and the caller reference you pass come back on the AddonPurchased event. A tip routes money to a
merchant, which Shopify's billing cannot do, so it is refused.
Usage
The driver reports no usage to Shopify. A tier whose meters price usage past the allowance, with a unit_price
or a provider_price, would have its units counted and never charged, so Checkout::subscribe() refuses it
with NotPossibleOnShopify. A meter that only counts against an allowance, with no price, works as everywhere.
Sell units in advance instead, as an add-on whose purchase grants them: the package keeps prepaid units itself,
and they roll over from one period to the next until they are used.
How the state arrives
Each app_subscriptions/update names the subscription that moved, and each app_purchases_one_time/update the
purchase. The driver reads the state back from the shop and reports a subscription's as the package's own:
| Shopify | The package |
|---|---|
ACTIVE | trialing while the trial runs, then active |
FROZEN | past_due, held by Shopify until the merchant pays |
| canceled | ended, or grace until the end a cancellation of yours set |
A subscription the merchant approves replaces the one they had, and Shopify cancels the old one. That
cancellation is reported as nothing at all, so changing a plan never takes it away in between. A subscription that
never ran is reported as nothing too: one that waits for the merchant's approval (PENDING, or the deprecated
ACCEPTED), and one they declined or let expire (DECLINED, EXPIRED). It gave the merchant nothing, and a plan
change opens it beside the subscription they keep. A subscription your app did not open through the package is
ignored.
When a merchant removes the app, Shopify cancels its subscriptions and sends app/uninstalled, and nothing
else: no app_subscriptions/update arrives for that cancellation, and the shop no longer answers the app. So
app/uninstalled ends every subscription of the shop at once, without asking the shop. Its body is the shop, the
same body shop/update sends, so the driver tells the two apart by the X-Shopify-Topic header and reads the shop
from the signed body. billing:webhooks:replay rebuilds a delivery from its stored body, without that header, so a
replayed uninstall ends nothing. Shopify's own retries carry the header.
billing:sync reads the active subscription of each owner back the same way. Shopify lists only the active
ones, so where an installation reports none, the sync reads the owner's latest subscription back by its id: a
cancellation that no webhook reported, after an outage of your endpoint for one, ends there as well.
Canceling and changing a plan
Shopify cancels a subscription at once. So SubscriptionActions::cancel() cancels it at Shopify without a
credit, since the merchant paid for the period in progress, and keeps the plan until that period ends. Shopify
reports nothing when that end arrives, so the hourly billing:run ends the subscription then and takes the
plan back. cancelAt() does the same to a moment inside the period, and cancelNow() ends it at once.
A plan changes, and a canceled subscription starts again, only through a new subscription the merchant
approves. swap() and resume() cannot hand back the page where the merchant does, so they refuse with
NotPossibleOnShopify. Open the new subscription with Checkout::subscribe() instead; Shopify replaces the
current one once it is approved.
The account hub reads this from the driver, whose capabilities report supportsInPlaceSubscriptionChanges as
false. Its plan screen opens the checkout for every plan change, a downgrade included, and schedules nothing for
the end of the period. Its preview of a change shows no figure, since Shopify shows the price on the page where the
merchant approves it. After a cancellation, the subscription screen and the banner offer the plans instead of a
resume.
Testing with a development store
Set BILLING_SHOPIFY_TEST=true and the driver opens every subscription and purchase as a test charge, which a
development store approves without paying. Leave it unset in production: a live store charged in test mode pays nothing.