Files
ns_erpnext_app/docs/DESIGN_SPEC.md
norman 8bb8954ff6 docs: add architecture, design spec, and security notes
Analyze the NS App and document what it does: NMI card payments on
Sales Invoices, AutoPay vaulting, multi-invoice settlement, guided
Customer Quick Entry, and branded print formats.

- docs/ARCHITECTURE.md  — functional overview, component map, payment
  flow, data model, dependencies, config, known tech debt
- docs/DESIGN_SPEC.md   — goals, requirements, API contracts, failure
  modes, acceptance criteria
- docs/SECURITY_NOTES.md — posture + hardening list
- docs/README.md        — docs index; link from root README

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 07:33:32 -04:00

10 KiB

NS App — Design Specification

Status: Living document · reverse-engineered from the current implementation (branch main, commit 4e0acde). Owner: NS Innovations Engineering Applies to: ns_app Frappe/ERPNext custom application.

This spec describes the intended behavior, contracts, and design constraints of NS App so the system can be maintained, extended, and re-implemented consistently. For a component tour see ARCHITECTURE.md.


1. Goals & non-goals

Goals

  • Let staff take a card payment without leaving the Sales Invoice, and have the ledger (Payment Entry) update automatically and correctly.
  • Support saved cards (AutoPay) for frictionless repeat/recurring billing.
  • Allow one card charge to settle multiple outstanding invoices.
  • Keep the app PCI-light: card data never transits ERPNext.
  • Speed up customer onboarding with a single guided dialog that produces a complete, linked Customer/Contact/Address.

Non-goals

  • The app is not a general payment-gateway abstraction — it targets NMI specifically.
  • It does not manage subscriptions/scheduling itself; "AutoPay" here means a vaulted card that can be charged on demand or via webhook, not a scheduler.
  • It does not own the custom-field schema on Customer (assumed present).

2. Personas & primary use cases

Persona Use case
AR / billing clerk Opens an unpaid invoice, clicks Run Payment, keys the customer's card, optionally saves it for AutoPay.
Clerk (repeat customer) Opens an unpaid invoice for a customer with AutoPay; confirms a one-click charge of the saved card.
Clerk (bulk settle) Charges one card for several of a customer's open invoices at once.
Sales user Creates a new customer from any link field via the guided Quick Entry dialog.
Payment gateway (system) Posts an async confirmation to the webhook, which reconciles a Payment Entry.

3. Functional requirements

3.1 Payment button visibility

  • Shown only when: docstatus == 1 (submitted) and a customer is set and outstanding_amount > 0.
  • When outstanding_amount <= 0: show a green Paid dashboard indicator, no button.
  • Otherwise: show a red Unpaid indicator plus Run Payment under Actions.

3.2 AutoPay-vs-manual branching

  • On Run Payment, call check_autopay(customer).
  • If autopay_enabled and autopay_id present → confirm dialog → charge the vaulted card (run_autopay_payment).
  • Else → open the manual card-entry dialog.

3.3 Manual payment dialog

  • Collects: first name, last name, company (optional), billing ZIP.
  • Renders Collect.js inline fields for card number / expiry / CVV.
  • Prefills name/company/ZIP from the invoice's customer where possible.
  • Optional Save for Auto Pay checkbox.
  • Optional Pay Additional Invoices toggle → loads the customer's other open invoices (get_unpaid_invoices) into a selectable table with a running selected-total and a select-all control.
  • Pay button label reflects the current selected total.

3.4 Charge semantics (server)

run_token_payment must:

  1. Resolve invoice_names (JSON string → list; fall back to [invoice]).
  2. Force save_autopay = 0 when enable_autopay_signup is falsy.
  3. Load every selected invoice; reject if any is not submitted or already fully paid.
  4. Sum outstanding_amount across selected invoices as the charge amount.
  5. Generate a unique orderid (<invoice-label>-<hash>).
  6. POST a type=sale transaction to NMI with the payment_token.
  7. On response == "1":
    • Dedup: if a Payment Entry already exists with reference_no == transactionid, return duplicate: true and do nothing.
    • Else create one Payment Entry allocated across all selected invoices.
    • If vaulting requested and a vault_id returned, persist AutoPay fields on the Customer.
  8. On failure: return {success: False, error} and create no Payment Entry.

3.5 AutoPay charge (server)

run_autopay_paymentcall_payment_api:

  • Requires custom_auto_pay_status and custom_auto_pay_id.
  • POSTs type=sale with customer_vault_id (no token, no card entry).
  • Derives mode_of_payment from the response type (check → ACH, else Credit Card).
  • Same dedup + create_payment_entry path.

3.6 Payment Entry creation (invariant)

create_payment_entry(invoices[], transaction_id, mode_of_payment):

  • Idempotent on reference_no == transaction_id.
  • paid_to = "ENB Bank Account - NIL" for ACH/Credit Card, else the company's default cash account; throw if none resolved.
  • payment_type = Receive, party = customer of the first invoice.
  • One references row per invoice, allocated_amount = outstanding_amount.
  • Insert and submit with ignore_permissions=True.

3.7 Webhook

crystalclear_webhook (allow_guest=True):

  • Ignore unless response == "1".
  • Map orderid → Sales Invoice, create a Payment Entry via the shared path.
  • Always return a short string ack.

3.8 Customer Quick Entry

  • Override ERPNext's Customer quick-entry dialog globally, preserving the originating after_insert link-field callback.
  • create_customer_full requires: customer_name, customer_type, customer_group, mobile_no, address_line1, pincode, country.
  • Enforce: if custom_auto_pay_enabled then custom_auto_pay_id required.
  • Create Customer + Contact + Address in a single transaction; rollback on any error and log.
  • Return the new customer name; caller writes it back into the triggering field via after_insert({ name }).

4. Interface contracts (server API)

All are @frappe.whitelist() unless noted. Return values are dicts consumed by frappe.call on the client.

Method Args Returns
check_autopay customer {autopay_enabled: bool, autopay_id: str|None}
get_unpaid_invoices customer [{name, posting_date, customer_name, outstanding_amount}]
run_token_payment invoice, token, invoice_names?, first_name?, last_name?, company?, billing_zip?, save_autopay? {success, transaction_id?, vault_id?, duplicate?, error?}
run_autopay_payment invoice {success, message, transaction_id} or throws
save_to_autopay customer, token, first_name?, last_name?, company?, billing_zip? {success, vault_id?} / {success:False, error}
crystalclear_webhook form dict (guest) "ok" / "ignored"
create_customer_full **data (see 3.8) new customer name (str) or throws

Error conventions

  • Validation / preconditionsfrappe.throw (surfaces as a msgprint).
  • Gateway / recoverable{success: False, error: <message>}.
  • Post-charge Payment Entry failures are caught and logged (the charge already succeeded) — they must not raise to the client.

5. Design constraints & rationale

Constraint Rationale
Card fields via Collect.js iframes only Keep PAN/CVV out of ERPNext → minimal PCI scope.
Dedup on gateway transactionid The charge is the source of truth; retries/webhook races must not double-post to the ledger.
Charge-then-record ordering, with PE errors logged not raised Never lose money already captured at the gateway; reconcile a missing PE manually rather than re-charging.
enable_autopay_signup feature flag Ship vaulting code dark; enable per-site only after testing.
Preserve after_insert in quick entry Only correct way to resume the originating document flow without racing the create transaction.
Single DB transaction in create_customer_full Never leave an orphan Customer without Contact/Address.

6. Idempotency, concurrency & failure modes

  • Double-click / double-submit: client guards with window.ns_payment_processing; server guards with the reference_no dedup.
  • Charge succeeds, PE fails: logged under PAYMENT ENTRY FAILURE AFTER SUCCESSFUL CHARGE; invoice stays unpaid in ERPNext until reconciled. Recovery: re-run create_payment_entry using the logged transaction_id (idempotent).
  • Webhook after interactive PE: dedup prevents a second PE.
  • Gateway unreachable: returns a generic error; no PE created.
  • Multi-invoice partial validity: if any selected invoice is unpaid-invalid (not submitted / zero balance) the whole request is rejected before charging.

7. Security requirements

  • nmi_security_key server-side only (site_config.json); never sent to the client.
  • HTTPS for every outbound gateway call.
  • Store only vault IDs, never card data.
  • Recommended hardening (not yet implemented):
    • Authenticate the webhook (shared secret / signature) before creating PEs.
    • Move the Collect.js tokenization key and paid_to account into config.
    • Scrub gateway response logging to guarantee no PAN/PII is written.
    • Rate-limit / permission-check payment endpoints.

8. Open questions / future work

  • Should AutoPay include a scheduler (true recurring billing) rather than on-demand vault charges only?
  • Ship the Customer custom_* fields as fixtures so installs are self-contained.
  • Consolidate custom.js and customer_quick_entry.js.
  • Make paid_to company-aware instead of the hard-coded ENB account.
  • Add automated tests around create_payment_entry idempotency and multi-invoice allocation.

9. Acceptance criteria (smoke test)

  1. Submitted unpaid invoice shows Run Payment; paid invoice shows Paid.
  2. Manual charge with a test card creates exactly one submitted Payment Entry; invoice outstanding goes to 0.
  3. Repeating the same gateway transaction (webhook replay) creates no second PE.
  4. AutoPay customer: Run Payment → confirm → one-click charge succeeds.
  5. Multi-invoice: selecting N invoices produces one PE with N allocations summing to the charged amount.
  6. Quick Entry creates a linked Customer/Contact/Address and populates the originating link field.
  7. With enable_autopay_signup = 0, checking Save for Auto Pay creates no vault entry.