DECISIONS — where the engine's design deviates from the original implementation plan
On this page
This is the generic, deployment-agnostic subset of a larger decisions record. It documents why the engine's code is shaped the way it is — every deviation was forced by a verified fact about ERPNext 16.34.2 / Frappe 16.33.1 while reconciling an implementation plan written against 16.30 with the actual source and a live deployment's data on 2026-09-14. It deliberately omits anything specific to one deployment (customer names, pricing, subscription/invoice IDs, migration-run numbers) — that lives in the private app that consumes this engine, since this repo is public.
History note: an earlier revision of this file carried deployment-specific detail (customer names, pricing, subscription and invoice IDs). It was scrubbed to the generic subset below, but that revision is still reachable in this repository's git history. Nothing in the current tree identifies a customer; the specifics live in the private app that consumes this engine.
Deviations baked into the specs
| ID | Plan said | Spec does | Why (evidence) |
|---|---|---|---|
| D-01 | Per-line periods in core Sales Invoice Item.service_start_date/service_end_date, widened via two depends_on property setters | Two app-owned Date fields subscription_period_start / subscription_period_end; no property setters; core's deferred block kept verbatim | AccountsController.validate calls clear_stale_deferred_fields() on every non-return save (accounts_controller.py:282-287, :671-686) and nulls both dates on non-deferred rows — verified empirically against a live deployment's data. |
| D-02 | Customer.consolidate_subscription_invoices default 1 | default "0"; a deployment's own migration is responsible for turning it on for its customers | A Check column is created DEFAULT cint(default) (frappe/database/schema.py:229-230,255-256) and backfills every existing row, which would silently route every existing customer (test fixtures included) through the consolidation runner. |
| D-03 | "Every override short-circuits when the customer has no anchor mode" (plan §Architecture) | Two independent switches: anchoring/proration keys on subscription_billing_anchor_mode; consolidation keys on consolidate_subscription_invoices; the invoice lookups key on party_type == "Customer". "All fields blank ⇒ stock" remains the invariant and the core ERPNext subscription test suite the gate. | Consolidation needs to work with anchoring off (a deployment may consolidate invoices without changing anyone's billing dates); gating the runner on anchor mode alone would force every consolidating customer to also be anchored. |
| D-04 | subscription_billing_anchor_date mandatory when mode = Calendar | Calendar ignores the date (effective anchor Jan 1 / day 1); Anniversary requires it; server-side Customer.validate enforces it (mandatory_depends_on is JS-only, frappe/public/js/frappe/form/save.js:269-282) | The date is meaningless for Calendar and indispensable for Anniversary; the plan's row was inverted. |
| D-05 | Subscription Settings.mid_term_billing_mode default "Next Daily Run" | default Immediate; "Next Daily Run" overrides generate_invoices_till_date() and adds a validate guard (BackdatedStartNotSupported) | 16.34's after_insert bills synchronously (stock = Immediate); a deferred daily process(today) is capped at one cycle and silently re-anchors, losing back-dated periods. The runner commits between groups and must not run inside a user's insert request. |
| D-06 | Line enrichment (links, period, description) implied to be part of the anchored path | _enrich_items runs on both paths for Customer subscriptions | Needed for consolidation and the line-aware lookups to work for a consolidating-but-not-yet-anchored customer, and to give every engine-generated invoice line a stable description independent of any host app's own logic; core amounts/dates/statuses unchanged on the stock path. |
| D-07 | Proration = get_plan_rate(..., prorate_factor=factor) | Anchored invoices are saved with ignore_pricing_rule = 1; non-anchored invoices keep Pricing Rules | Pricing Rules overwrite rate on every save (accounts_controller.py:1218-1240, taxes_and_totals.py:203), silently defeating the engine's own prorated rate the moment a customer with an active Pricing Rule is anchored — verified against a real Pricing Rule on a live deployment. A deployment with customer-specific Pricing Rules needs to move that pricing onto the plan itself before anchoring that customer (see the deployment's own decisions record). |
| D-08 | PR 3 adds a daily_long scheduler hook | No new scheduler hook; core's daily_maintenance → create_subscription_process → process_all → the engine's process() delegation is the only entry point | A second daily entry would only add lock contention. |
| D-09 | Group key (company, party_type, party, currency, tax_template, posting_date) | Key also includes cost_center, accounting dimensions, submit_invoice, is_trialling(), additional discounts, apply_additional_discount, days_until_due | Everything core copies from the group's first subscription onto the invoice header must be equal across every member of the group. |
| D-10 | Three lookup overrides; has_outstanding_invoice excludes returns | Four Subscription overrides: get_current_invoice, invoices, has_outstanding_invoice, is_paid, plus is_current_invoice_generated keyed on the subscription's own line window | Credit Note Issued ≠ Paid would block every sibling on a consolidated invoice; header posting_date check would re-bill members whose period start differs from the invoice's own. |
| D-11 | PR 4 fixes the dead posting-date guard; PR 5 adds if doc.is_return: return | subscription_payment_request rewritten: skip returns / non-positive outstanding / kill switch / migrate flags, gateway resolved from line plans, transaction_date = due_date, stale invoices left as a draft Payment Request | Late fires are normal and must charge; a credit note makes core's make_payment_request throw and abort the submit if not excluded first. |
| D-12 | cancel_subscription override gated on anchor mode | Gate = Customer AND (anchored OR consolidating) AND credit_note_on_cancellation | Between a deployment's migration and its anchoring rollout, every consolidating customer is un-anchored; an anchor-only gate would keep core's no-credit cancellation for the whole book during that window. |
| D-12a | (added during credit-note implementation) | The credit-note route additionally requires generate_invoice_at != "End of the current subscription period" | A postpaid subscription has nothing prepaid to credit; core's arrears-invoice cancellation is the correct outcome there, and is what the core cancellation tests pin. |
| D-13 | Migration gate: is_current_invoice_generated() True for every Subscription | Gate = would-bill parity vs. a pre-migration snapshot | At rest every subscription's current period is the next unbilled one → the plan's original predicate is false for every non-cancelled subscription, on any deployment. |
| D-14 | Historical-invoice-line attribution matches on (item_code, strip_html(description).lower()); unmatched lines fall back to the parent | Match by a deployment's own audit-log mining for dates; multiset consumption + description-history keys + unique-item fallback for line attribution; unmatched lines stay unlinked rather than guessed | Positional or description-only matching can be provably wrong when items were renamed or reordered; pinning an unmatched line to an arbitrary sibling fabricates history. |
| D-16 | The host app's positional line-description logic must be removed "later" than the consolidation runner ships | Both must ship in the same deployment together | A stale positional-description hook raises an IndexError (not a ValidationError) on a consolidated invoice's second line append, which aborts the whole scheduler batch. |
| D-17 | Subscription.service_identifier: in_standard_filter, property setter search_fields | also in_list_view: 1; search_fields = "party, service_identifier" | A deployment with many per-item subscriptions needs the identifying detail visible in the list, not just filterable. |
| D-18 | Runner internals (a global in-memory sink flag, a generic lock name, etc.) | Run context on frappe.local.consolidated_billing; lock name = a hash of (company, party_type, party); savepoint per group; re-check with for_update=True; a dedicated ConsolidationLocked exception | filelock is not reentrant and uses its name as a raw filesystem path component; the standard integration-test base class deep-copies frappe.local.flags between tests, so a Document reference stored there would be silently duplicated. |
| D-19 | The consolidation runner relies on the invoice lookups for a grace-period correctness guard | The runner adds its own run-scoped get_current_invoice guard, independent of the lookup overrides | Makes the runner correct in isolation, so the two pieces can ship in either order without a live-money hazard in between. |
| D-20 | Sales Invoice.subscription "has no index today" (noted, not fixed) | A property setter plus a patch adding the DB index explicitly | A property-setter-only customization file never triggers a schema sync; a bare index added outside the customization framework is dropped by the next schema sync. |
| D-21 | Subscription Settings gets 3 new fields | 4: also a lateness threshold for auto-charging | The engine reads settings through its own accessor because Frappe's "Singles" defaults are not materialised into the database until the single document is saved once — a bare field default is invisible to a query. |
| D-22 | Test scaffolding assumes freeze_time and per-test rollback | "Today" is frozen by patching the lowest-level date function directly; rollback is explicit in tearDown; fixtures are created in setUp | The freezegun package isn't a dependency here; the integration test base class only rolls back once per test class, not per test method. |
| D-23 | Invoice status-refresh hook set: the Subscription-status-refresh mixin only | Also an on_cancel doc event; deliberately no on_submit status hook | Desk-driven cancellation happens outside the engine's own process() call; wiring a status refresh into on_submit would raise a timestamp-mismatch error from inside the consolidation runner, which already re-saves the subscription itself in the same call. |
Where deployment-specific decisions live
Sections that existed in the original, unredacted version of this file — open questions posed to a specific deployment's maintainer, their answers, and any customer- or invoice-specific verification detail — are not included here. If you're building your own deployment of this engine and want to see a worked example of how those decisions were made (anchor rollout order, a real Pricing-Rule-vs-plan-rate conflict, how a migration's open questions were tracked and resolved), look at the private app that first adopted this engine; that record necessarily contains real customer and business data, which is why it isn't in this repo.