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>
10 KiB
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 acustomeris set andoutstanding_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_enabledandautopay_idpresent → 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:
- Resolve
invoice_names(JSON string → list; fall back to[invoice]). - Force
save_autopay = 0whenenable_autopay_signupis falsy. - Load every selected invoice; reject if any is not submitted or already fully paid.
- Sum
outstanding_amountacross selected invoices as the charge amount. - Generate a unique
orderid(<invoice-label>-<hash>). - POST a
type=saletransaction to NMI with thepayment_token. - On
response == "1":- Dedup: if a Payment Entry already exists with
reference_no == transactionid, returnduplicate: trueand do nothing. - Else create one Payment Entry allocated across all selected invoices.
- If vaulting requested and a
vault_idreturned, persist AutoPay fields on the Customer.
- Dedup: if a Payment Entry already exists with
- 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_statusandcustom_auto_pay_id. - POSTs
type=salewithcustomer_vault_id(no token, no card entry). - Derives
mode_of_paymentfrom the responsetype(check→ ACH, else Credit Card). - Same dedup +
create_payment_entrypath.
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
referencesrow 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_insertlink-field callback. create_customer_fullrequires:customer_name,customer_type,customer_group,mobile_no,address_line1,pincode,country.- Enforce: if
custom_auto_pay_enabledthencustom_auto_pay_idrequired. - 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 thereference_nodedup. - Charge succeeds, PE fails: logged under
PAYMENT ENTRY FAILURE AFTER SUCCESSFUL CHARGE; invoice stays unpaid in ERPNext until reconciled. Recovery: re-runcreate_payment_entryusing the loggedtransaction_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_keyserver-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_toaccount 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.jsandcustomer_quick_entry.js. - Make
paid_tocompany-aware instead of the hard-coded ENB account. - Add automated tests around
create_payment_entryidempotency and multi-invoice allocation.
9. Acceptance criteria (smoke test)
- Submitted unpaid invoice shows Run Payment; paid invoice shows Paid.
- Manual charge with a test card creates exactly one submitted Payment Entry; invoice outstanding goes to 0.
- Repeating the same gateway transaction (webhook replay) creates no second PE.
- AutoPay customer: Run Payment → confirm → one-click charge succeeds.
- Multi-invoice: selecting N invoices produces one PE with N allocations summing to the charged amount.
- Quick Entry creates a linked Customer/Contact/Address and populates the originating link field.
- With
enable_autopay_signup = 0, checking Save for Auto Pay creates no vault entry.