# 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](./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` (`-`). 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_payment` → `call_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 / preconditions** → `frappe.throw` (surfaces as a msgprint). - **Gateway / recoverable** → `{success: False, error: }`. - **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.