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>
218 lines
10 KiB
Markdown
218 lines
10 KiB
Markdown
# 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` (`<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_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: <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.
|