For setup, see Installation: Craftivus Subscription. This page is about the codebase and the integration contract with customer sites.
Before building this app, the honest question was asked and verified against ERPNext's own source rather than assumed: does ERPNext's native Subscription doctype already do most of this? The answer is yes for the billing mechanics, no for the cross-site integration:
What ERPNext already gives you, verified by reading subscription.py directly:
Subscription ties a party to one or more Subscription Plans, tracks status (Trialing, Active, Grace Period, Unpaid, Cancelled, Completed), auto-generates invoices on a schedule.cancel_subscription() / restart_subscription(), with Desk buttons already rendered — free "stop the plan" / "resume it."Subscription.process() can auto-cancel a subscription itself (via Subscription Settings.cancel_after_grace) without ever calling cancel_subscription() — this is why the push hook (below) must be a generic on_update status-diff check, not a wrapper around that one method. Missing this would silently leave a non-paying customer's till thinking it's still Active forever.cancel_subscription() bills a final invoice for the elapsed partial period by default — correct for a voluntary cancellation, worth a deliberate policy decision for a non-payment-driven one (currently accepted as-is, not suppressed).What it doesn't give you, and what this app adds:
plans child table directly.service_type splitOne field, service_type (POS / ERPNext Only), lives on Subscription Plan. Every Subscription inherits it from whichever plan is assigned. This is the single decision point for everything downstream — which fields are shown (depends_on on the custom fields), and which integration path a status/plan change dispatches to (events/subscription.py's apply_state()).
| POS | ERPNext Only | |
|---|---|---|
| Plan-level fields | device_cap, outlet_cap, feature-flag checkboxes |
user_limit, storage_limit_gb, included_modules |
| Enforcement | Pushed to the customer's craftivus_core; the till enforces itself |
Directly enables/disables the customer's own Frappe Users |
Customer needs craftivus_core installed? |
Yes | No |
Every .py file lives in its own folder — api/, events/, integration/, utils/, exceptions/ — hooks.py is the one exception, forced by Frappe to sit at the app root.
events/subscription.py — on_update(doc, method), the dispatch hook. Compares status/plan before and after save; if either changed, calls apply_state(), which reads the linked plan's service_type and routes to integration/pos.py or integration/erpnext_only.py.integration/pos.py — push_state(), revoke_all_devices(). HTTP calls to the customer's own craftivus_core.api.subscription.*, authenticated with X-Craftivus-Push-Key (never Authorization: token — see below). Never raises on failure; returns {"ok": False, "error": ...} so a customer's site being briefly unreachable never blocks the Subscription save that triggered the push.integration/erpnext_only.py — apply_status()/set_users_enabled(). Plain Frappe REST calls (/api/resource/User/{name}) against the customer's own site, using stored admin_api_key/admin_api_secret — a real Frappe User's own credentials, so this one does use standard Authorization: token correctly.api/plan_change.py — change_plan(), the one genuinely new action ERPNext doesn't provide. Validates the new plan's caps against cached usage before allowing a downgrade (force=1 overrides), swaps the plans row, pushes immediately — independent of ERPNext's own next-invoice-date billing cycle for the entitlement change (the price change still follows ERPNext's normal cycle).api/revoke.py — revoke_subscription(). Auto-cancels the subscription if not already cancelled, sets revoked/revoked_by/revoked_on/revoke_reason, then calls revoke_all_devices(). Not reversible from this app — restarting the subscription only restores billing/entitlements, never un-revokes a device (mirrors POS Device's own one-way Revoked state).Authorization: tokenVerified live during development: sending Authorization: token {push_key} gets intercepted by Frappe's own auth middleware before the request ever reaches receive_state() or revoke_all_devices() — Frappe expects that scheme to be a real User's api_key:api_secret pair and 401s anything else, regardless of allow_guest=True on the target method. The fix, used consistently on both sides of this integration, is a custom header (X-Craftivus-Push-Key) checked manually in code with secrets.compare_digest.
Subscription belongs to erpnext, not craftivus_subscription. The Change Plan / Revoke buttons are added via a real, versioned public/js/subscription.js file, wired through the doctype_js hook in hooks.py:
doctype_js = {"Subscription": "public/js/subscription.js"}
This is the correct mechanism for customizing a doctype you don't own — Frappe concatenates every installed app's registered doctype_js for a given doctype. Do not create a runtime "Client Script" database record via a patch for this — it works, but it isn't version-controlled the way a real file in the app is, and it was tried first and explicitly replaced with this approach during development.