Jump to
↵select↑↓navigateescclose

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

IDPlan saidSpec doesWhy (evidence)
D-01Per-line periods in core Sales Invoice Item.service_start_date/service_end_date, widened via two depends_on property settersTwo app-owned Date fields subscription_period_start / subscription_period_end; no property setters; core's deferred block kept verbatimAccountsController.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-02Customer.consolidate_subscription_invoices default 1default "0"; a deployment's own migration is responsible for turning it on for its customersA 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-04subscription_billing_anchor_date mandatory when mode = CalendarCalendar 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-05Subscription 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-06Line enrichment (links, period, description) implied to be part of the anchored path_enrich_items runs on both paths for Customer subscriptionsNeeded 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-07Proration = get_plan_rate(..., prorate_factor=factor)Anchored invoices are saved with ignore_pricing_rule = 1; non-anchored invoices keep Pricing RulesPricing 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-08PR 3 adds a daily_long scheduler hookNo new scheduler hook; core's daily_maintenance → create_subscription_process → process_all → the engine's process() delegation is the only entry pointA second daily entry would only add lock contention.
D-09Group 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_dueEverything core copies from the group's first subscription onto the invoice header must be equal across every member of the group.
D-10Three lookup overrides; has_outstanding_invoice excludes returnsFour Subscription overrides: get_current_invoice, invoices, has_outstanding_invoice, is_paid, plus is_current_invoice_generated keyed on the subscription's own line windowCredit 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-11PR 4 fixes the dead posting-date guard; PR 5 adds if doc.is_return: returnsubscription_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 RequestLate 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-12cancel_subscription override gated on anchor modeGate = Customer AND (anchored OR consolidating) AND credit_note_on_cancellationBetween 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-13Migration gate: is_current_invoice_generated() True for every SubscriptionGate = would-bill parity vs. a pre-migration snapshotAt 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-14Historical-invoice-line attribution matches on (item_code, strip_html(description).lower()); unmatched lines fall back to the parentMatch 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 guessedPositional 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-16The host app's positional line-description logic must be removed "later" than the consolidation runner shipsBoth must ship in the same deployment togetherA 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-17Subscription.service_identifier: in_standard_filter, property setter search_fieldsalso 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-18Runner 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 exceptionfilelock 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-19The consolidation runner relies on the invoice lookups for a grace-period correctness guardThe runner adds its own run-scoped get_current_invoice guard, independent of the lookup overridesMakes the runner correct in isolation, so the two pieces can ship in either order without a live-money hazard in between.
D-20Sales Invoice.subscription "has no index today" (noted, not fixed)A property setter plus a patch adding the DB index explicitlyA 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-21Subscription Settings gets 3 new fields4: also a lateness threshold for auto-chargingThe 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-22Test 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 setUpThe freezegun package isn't a dependency here; the integration test base class only rolls back once per test class, not per test method.
D-23Invoice status-refresh hook set: the Subscription-status-refresh mixin onlyAlso an on_cancel doc event; deliberately no on_submit status hookDesk-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.