Both use the text domain
cartpresets-for-woocommerce. That’s the free plugin’s wordpress.org slug, and language packs are keyed on the slug, so one set of translations serves both. Every data-bearing identifier is tier-neutral too: tables, options, hooks and the cartpresets/v1 REST namespace are the same on both. That’s why moving between them migrates nothing.
Storage
Five custom tables, not custom post types.
Presets are queried by shape: “active, containing this product, published this month, most orders first”. That’s a job for SQL, with indexes on the columns you filter by. The same queries against post meta would be a pile of joins on a table shared with every other plugin on the site.
Settings that don’t need indexing sit in a JSON blob on the row. Anything the listing filters or sorts by gets a real column.
The tier boundary
src/Pro/ and assets/src/pro/ hold all of the premium code. The free version is built from the same tree with those two directories deleted.
Nothing in shared code names a Pro class. Pro attaches to the same public filters any third-party plugin would use, documented in the hooks reference.
That has a cost: a premium feature needs a filter to attach to, so shared code carries filters that exist only for Pro to attach to. The alternative is if ( is_pro() ) at every decision point, which means worse code and a leakier boundary.
Which way the gates point
On its own, shared code gives the free version’s answer. Pro is a listener that hands it the merchant’s stored choices:
Shared code never reads a premium setting itself. So the premium behaviour isn’t in the free version, waiting to be switched on.
Stored dates and caps keep applying on the free version. Only changing them needs Pro. A downgrade can’t put a link back on sale that the merchant’s own schedule or cap had stopped. The coupon is the exception: free doesn’t apply a stored code, because that would be a discount the merchant can’t see or change there.
The tier is the build, never the licence
Tier::is_pro() reads whether the Pro code is on disk. It never reads licence state.
An expired licence leaves every Pro file where it was, so every gate keeps answering yes. A test makes sure no entitlement check can reach the tier decision.
The storefront footprint
No styles and no scripts are ever enqueued on the front end. An ordinary front-end page view costs no database queries from this plugin, because the settings are one autoloaded option. It parses 27 PHP files, about 1.6% of what a WooCommerce request already loads. The cart-integrity hooks (price override, quantity lock, cascade removal) only attach when a cart actually holds a preset. A shopper browsing your catalogue runs none of this plugin’s code beyond the check that decides there’s nothing to do. It’s also why there’s no analytics subsystem: a preset hit writes nothing. Sales enter the books when WooCommerce processes the order, on hooks that never run on the storefront.Pricing runs twice, in two languages
Support\Pricing and Support\Totals in PHP are the authority. assets/src/lib/pricing.js mirrors them. It’s there only because the editor’s live summary has to recompute a total in the browser as you type, and it can’t call PHP for that.
Two hand-maintained copies of the same arithmetic tend to drift apart. Both run against a shared fixture table in the test suite, so a formula changed on one side and not the other fails a test before it can charge the wrong amount.
Changing a formula means changing three things: the PHP, the JS, and the fixture.
Prices are never cached
Item definitions are cached, because they only change through the plugin’s own save paths. That’s a closed set to invalidate. Prices never are. A cached price goes stale as soon as a product is edited, a sale starts or a tax rate changes. A stale price in a cart is a wrong charge.The admin is a React app
One admin page, one script and one stylesheet, loaded on that page and nowhere else. The panel has four screens: the list, the editor, Settings and the upgrade screen. Moving between them doesn’t reload the page. The whole route lives in the query string, so a view survives a reload, you can share it as a link, and the browser’s back button brings it back. React renders the design’s own markup: its elements, classes and ARIA. Every rule that decides how they look is in one stylesheet, with light and dark themes as sets of tokens. No component carries its own styles, and the panel doesn’t use jQuery or@wordpress/components. The screens share one set of components, so a fix lands everywhere at once.
The Analytics report is the exception. It lives in WooCommerce Analytics, so it’s built from WooCommerce’s own components and ships as a separate Pro bundle.
At boot the server hands the panel everything it needs in one inline payload: REST base, tier, currency, store settings, the store-wide preset counts, the outbound links and the wording catalogue. The panel never has to make a request just to find out what it is. The boot payload lists every key.
You can filter that payload through cartpresets_admin_boot. It’s how Pro publishes state that shared code must not know about.
Order lines carry their own record
At checkout, every preset line is stamped with the preset ID, the preset name as it was at that moment, the instance ID, and the ID of the preset item the line came from. The name is a snapshot, so renaming a preset next year doesn’t rewrite what last year’s orders say. See Order line item meta.One answer for a refused link
A preset link can be refused ten ways, each with its own message: no such slug, not started, unavailable (ended, switched off or sold out), the total cap full, the per-customer cap full, not signed in, the wrong role, no items at all, an item that can’t be bought, or nothing addable at cart time.Support\Refusal::resolve() gives the single answer, and PresetUrlHandler::refuse() is the single caller. The keys are refused_action (cart or custom), refused_url and refused_notice, at store level and on each preset, where the action also takes global to follow the store. One level answers all three questions. Reading the destination from the preset and the notice switch from the store would be a combination neither screen ever shows.
A refusal and a partial success are handled apart. The split is empty( $added ): nothing reached the cart, or something did. Anything that reached it keeps the post-add destination, its own caveat notice, the automatic coupon and cartpresets_after_add_to_cart.
Personal data
One row holds it: a redemption. It’s written for every preset instance in an order that reaches a counted status, capped or not, because the count is what a cap reads later. A signed-in customer is recorded as their account, and a guest by their billing email, which is the only way to recognise the same guest twice. A claim waiting on payment also sits incartpresets_holds under a SHA-256 of the same key, until the order is paid or cancelled, or for a day at most.
Privacy registers a WordPress personal-data exporter and eraser for it, in wp-admin only. The eraser also blanks the hash on any hold. The eraser anonymises rather than deletes. A redemption row says both who claimed a preset and that it was claimed, so removing it would give a limited run its slot back. See Privacy and personal data.
Licensing and updates
Licences are sold and checked through Freemius, but the Freemius SDK isn’t bundled. Pro talks to the Freemius REST API directly from one small client. The SDK would have to start up on every request, front end included. This client runs from wp-admin and cron only, so licensing adds nothing to a storefront request. Freemius answers may this site update? The package itself comes from a release manifest atsmartwpplugins.com/updates/cartpresets-for-woocommerce-pro.json. The licence is checked before the manifest is fetched, and if either side fails it fails quietly: the Plugins screen shows what it would have shown anyway.
Licence state never decides the tier. See the tier is the build.
What is deliberately not extensible
These limits are deliberate:- No filter bypasses a capability or nonce check.
- No filter turns a hard validation failure back into a valid item.
- No filter bypasses purchase-limit enforcement at either stage.
- No filter disables cascade removal or weakens instance isolation.
- No filter sits between a query and
$wpdb->prepare(). - No PHP filter registers anything the React admin renders. A client-rendered extension point has to be registered client-side. A PHP filter that looks like it works and quietly does nothing is worse than no extension point at all.