18 Commits

Author SHA1 Message Date
a986348de9 Merge pull request 'fix(app): add modules.txt/patches.txt so bench keeps ns_app in apps.txt' (#23) from feature/customer-statements into production
Reviewed-on: #23
2026-07-21 18:45:17 +00:00
192af42614 fix(app): add modules.txt/patches.txt so bench keeps ns_app in apps.txt
ns_app was hand-created without the standard scaffolding files, so it was
missing modules.txt and patches.txt. bench's is_frappe_app() check requires
all of hooks.py, modules.txt, and patches.txt to exist; BenchApps.sync()
rebuilds sites/apps.txt from the folders that pass that check. sync() runs on
bench get-app, install-app (of any app), update, etc., so every such command
silently dropped ns_app from apps.txt.

Once ns_app was missing from apps.txt, `bench build --app ns_app` crashed
(esbuild get_public_path returned undefined), the sites/assets/ns_app symlink
was never created, and /assets/ns_app/js/customer_statements.js 404'd. That
left ns_statements undefined, so the Customer "Generate Statements" button's
handler did nothing and the statement screen never opened on production.

Add modules.txt ("NS App", matching app_title), an empty patches.txt, and the
"NS App" module package folder so bench recognizes ns_app as a Frappe app and
stops removing it. Run `bench --site <site> migrate` to register the module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 10:02:53 -04:00
1b41fb0845 Merge pull request 'add feature/customer-statements to erpnext' (#22) from feature/customer-statements into production
Reviewed-on: #22
2026-07-09 12:53:50 +00:00
41d3fec08c fix(statements): raise customer address window 0.5in
Move the customer address window up to 2.5in per printed proof, and
pull the body padding-top back up to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 08:20:44 -04:00
ccc3fac69d fix(statements): nudge envelope windows to printed-proof positions
Company (return) address down 0.25in (to 0.8in) and customer address
down 1.5in (to 3.0in) to line up with the #9 double-window envelope,
per a printed proof. Push the body padding-top to clear the lower
customer window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 08:06:37 -04:00
0aefdeb5ff fix(statements): set envelope geometry for #9 double-window
Reset the statement window positions to the app's proven #9 (9x4)
double-window geometry, mirroring sales_invoice_ns.html — recipient
window at top:1.5in/left:1.125in. It previously copied the dunning
format's 1.9in, which sits too low for a #9. Tighten the body
padding-top to keep clearance below the higher window, and correct the
stale #10 references in the template comment and docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 07:52:07 -04:00
326adf865a fix(statements): restore Customer list button (merge listview_settings)
The list button was registered by reassigning
frappe.listview_settings['Customer'] in a globally-loaded script, but
ERPNext's own Customer list_js (loaded when the list opens) overwrote it,
so the button never appeared. Register it via doctype_list_js instead —
which Frappe appends after the doctype's own list_js — and merge into the
existing settings (wrapping onload, preserving ERPNext's add_fields)
rather than reassigning. The form button and shared ns_statements helpers
stay in customer_statements.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:36:21 -04:00
5181f4a177 docs: describe the customer statements feature
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:58:09 -04:00
46967208e9 feat(statements): Customer-form button, consolidated UI, disable-fee toggle
Replace customer_list.js with customer_statements.js (loaded globally),
which adds the statement UI to both entry points:
- Customer list: 'Generate Statements' multi-select action.
- Customer form: 'Generate Statement' button for a single customer.

Both open a popup with a 'Generate late payment fee' checkbox (default on)
that maps to generate_statements(skip_late_fee), so fee billing can be
turned off per run. Shared generate/print helpers live on ns_statements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:52:15 -04:00
c20dd18287 feat(statements): untaxed late-fee series, skip-fee option, audit trail
- Bill late-fee invoices under a dedicated naming series (LPF-.YYYY.-),
  registered on Sales Invoice via after_migrate, so they are easy to spot
  and filter.
- Late fees are never taxed: a zero 'Actual' tax line keeps the taxes
  table non-empty so ERPNext skips auto-applying company/item tax
  templates (posts nothing to the ledger). Fee total == computed fee.
- generate_statements(skip_late_fee=...) generates a statement without
  billing a fee.
- Record every generation on the customer's timeline (add_comment) as an
  audit trail, noting Total Due and the fee invoice raised / skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:52:03 -04:00
acd7df1129 feat(statements): bill late fee as a collectible Sales Invoice
Charge a late-payment fee when statements are generated so the customer's
receivable reflects it (the gap ERPNext Dunning leaves — it never
increases AR). The fee is billed as a submitted Sales Invoice (item ->
Dunning Type income account, rate = computed fee) rather than a Journal
Entry, so the app's existing payment flow (Run Payment / AutoPay /
multi-invoice) charges and settles it automatically via its Sales Invoice
references — a bare JE would sit uncollected.

Fee schedule/amounts come from the existing Dunning Type settings (yearly
rate_of_interest + flat dunning_fee), interest computed with ERPNext's own
Dunning formula. The fee Item is configured via a new Late Fee Item custom
field on Dunning Type (created in an after_migrate hook; ns_app/setup.py).
Nothing is auto-seeded: generation stops with a clear error if no Dunning
Type is configured or its income account / fee item is unset.

Billing is idempotent per customer/company/month, and prior fee invoices
are excluded from the interest base (no fee-on-fee). The fee invoice shows
on the statement flagged 'late fee', folded into Total Due (which equals
the customer's balance and is fully collectible).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:35:04 -04:00
9e2e86cead feat(statements): add Customer list "Generate Statements" button
Register doctype_list_js for Customer and add customer_list.js, which
lists customers with overdue invoices in a selection dialog (checkbox
table + select-all), then calls generate_statements and opens the
printable, one-page-per-customer document in a new window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:08:14 -04:00
75a9c9d154 feat(statements): statement builder + printable envelope template
Add get_statement_data (open invoices, aging buckets, totals, formatted
customer + company addresses) and generate_statements, which renders one
page per customer via a Jinja template and returns a printable HTML
document. Recipient window geometry (top:1.9in/left:1.125in) mirrors the
existing double-window print formats for #10 envelope compatibility; each
page uses page-break-after:always. Late fee is a zero placeholder here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:04:47 -04:00
63bf0b68f5 feat(statements): add overdue-customers query API
Add ns_app/api/statements.py with get_customers_with_overdue_invoices
(one aggregated row per customer with overdue Sales Invoices) plus the
_get_outstanding_invoices / _aging_bucket helpers used to build the
per-customer statement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:01:03 -04:00
790d4d0e9d Merge pull request 'main' (#17) from main into production
Reviewed-on: #17
2026-06-15 14:14:35 +00:00
87f40e6b83 Merge pull request 'add autopay and fix customer entry form.' (#13) from main into production
Reviewed-on: #13
2026-05-18 15:27:57 +00:00
61ac7f563c Merge pull request 'update customer quick entry form and field descriptions' (#12) from main into production
Reviewed-on: #12
2026-05-07 23:47:50 +00:00
f89a672334 Merge pull request 'Added route options to monitor and auto populate fields based off where the quick entry form was spawned from.' (#9) from main into production
Reviewed-on: #9
2026-05-06 09:29:44 +00:00
15 changed files with 1005 additions and 497 deletions

View File

@@ -11,13 +11,6 @@ Storing the returned vault ID on the ERPNext Customer for future autopay use
This part of the app is designed to integrate directly into the Sales Invoice workflow.
Documentation
Full reference docs live in the docs/ folder:
- docs/ARCHITECTURE.md what the app does, components, payment flow, data model
- docs/DESIGN_SPEC.md requirements, API contracts, failure modes, acceptance criteria
- docs/SECURITY_NOTES.md security posture and hardening list
Features
Secure card entry using NMI Collect.js (tokenization)
Manual payment dialog inside ERPNext

View File

@@ -1,209 +0,0 @@
# NS App — Architecture & Functional Overview
> Custom ERPNext / Frappe application by **NS Innovations** that extends the
> standard Sales workflow with an embedded card-payment experience, AutoPay
> vaulting, a streamlined customer onboarding dialog, and branded print
> formats.
---
## 1. What the app does
NS App layers four capabilities on top of a stock ERPNext v-current install:
| Capability | Where it lives | Summary |
|------------|----------------|---------|
| **In-form card payments** | `ns_app/public/js/sales_invoice.js` + `ns_app/api/payments.py` | Adds a **Run Payment** button to submitted, unpaid Sales Invoices. Card data is tokenized client-side by NMI Collect.js and charged server-side via the NMI gateway. A Payment Entry is created and submitted automatically on success. |
| **AutoPay (card vaulting)** | `ns_app/api/payments.py`, Customer custom fields | A card can be saved to the NMI Customer Vault. The returned vault ID is stored on the Customer, enabling one-click recurring charges and webhook-driven payments. |
| **Multi-invoice payment** | `sales_invoice.js`, `get_unpaid_invoices`, `run_token_payment` | A single card charge can settle several of a customer's outstanding invoices at once, producing one Payment Entry allocated across them. |
| **Customer Quick Entry** | `ns_app/public/js/customer_quick_entry.js`, `custom.js`, `ns_app/api/customer.py` | Replaces ERPNext's default "New Customer" quick-entry dialog with a guided form that creates Customer + Contact + Address atomically, with ZIP-based city/state autofill. |
| **Branded print formats** | `ns_app/print_formats/` (shipped as fixtures) | Double-window envelope layouts for Invoice, Sales Order, Quotation, and Dunning. |
---
## 2. Component map
```
ns_app/
├── hooks.py # App manifest: JS injection points + fixtures
├── api/
│ ├── payments.py # Payment gateway integration (NMI)
│ └── customer.py # Atomic customer creation endpoint
├── public/js/
│ ├── customer_quick_entry.js # Overrides CustomerQuickEntryForm (global)
│ ├── custom.js # Legacy/alt quick-entry enhancer
│ └── sales_invoice.js # Payment UI on the Sales Invoice form
└── print_formats/print_formats/ # HTML/Jinja print templates (fixtures)
```
### Injection points (`hooks.py`)
- `app_include_js``customer_quick_entry.js` loads on **every** desk page
(needed because Customer quick-entry can be triggered from many forms).
- `doctype_js["Sales Invoice"]``sales_invoice.js` loads only on the Sales
Invoice form.
- `fixtures` → three Print Formats (`NS Invoice`, `NS Sales Order`,
`NS Quotation`) are version-controlled and synced on migrate.
> Note: `custom.js` is **not** referenced in `hooks.py`. It is an earlier
> iteration of the quick-entry enhancement, superseded by
> `customer_quick_entry.js`. See [Known issues](#8-known-issues--tech-debt).
---
## 3. Payment flow (end-to-end)
```
┌─ Sales Invoice form (submitted, outstanding > 0) ─────────────────────────┐
│ "Run Payment" button → check_autopay(customer) │
└────────────┬──────────────────────────────────────────────────────────────┘
┌───────┴────────┐
│ AutoPay on? │
└───┬────────┬───┘
yes no
│ │
▼ ▼
run_autopay_ open_manual_payment_form() ── Collect.js renders NMI-hosted
payment() │ iframe fields (PCI out of scope)
│ │
│ user enters card → CollectJS.startPaymentRequest() → payment_token
│ │
│ ▼
│ run_token_payment(invoice, token, invoice_names[], …)
│ │
▼ ▼
call_payment POST https://secure.nmi.com/api/transact.php (type=sale)
_api() │
│ response=1 ? ── no ──► error surfaced to UI, no Payment Entry
│ │
│ yes
└───────┬───────┘
create_payment_entry(invoices[], transaction_id, mode_of_payment)
│ (dedup on reference_no == transaction_id)
Payment Entry inserted + submitted → invoice outstanding updates
▼ (only if "Save for Auto Pay" checked AND enable_autopay_signup=1)
customer_vault=add_customer → vault_id stored on Customer
```
### The three server entry points to a charge
1. **`run_token_payment`** — interactive, one-time or multi-invoice card
charge from the manual dialog. Optionally vaults the card.
2. **`run_autopay_payment`** → **`call_payment_api`** — charges a previously
vaulted card (`customer_vault_id`) with no card entry.
3. **`crystalclear_webhook`** (`allow_guest=True`) — gateway-initiated
confirmation that creates a Payment Entry for the referenced invoice.
All three converge on **`create_payment_entry`**, which is idempotent on the
transaction ID (`reference_no`).
---
## 4. Data model (custom fields)
The app relies on custom fields on **Customer** (created outside this repo —
via ERPNext Customize Form / Custom Field, not shipped as fixtures here):
| Field | Type | Purpose |
|-------|------|---------|
| `custom_auto_pay_status` | Check | Whether AutoPay is enabled |
| `custom_auto_pay_id` | Data | NMI Customer Vault ID |
| `custom_auto_pay_first_name` | Data | Cardholder first name (vault) |
| `custom_auto_pay_last_name` | Data | Cardholder last name (vault) |
| `custom_auto_pay_company` | Data | Company on the vault record |
| `custom_auto_pay_zip` | Data | Billing ZIP on the vault record |
| `custom_send_via` | Select | Preferred delivery method (mail/email/fax) |
> ⚠️ These fields are a **required dependency** that is not tracked in this
> repository. See [Known issues](#8-known-issues--tech-debt).
---
## 5. Customer Quick Entry
`customer_quick_entry.js` subclasses `frappe.ui.form.CustomerQuickEntryForm`
and overrides `render_dialog()` to present a custom dialog instead of
ERPNext's. Key design points:
- **Preserves `this.after_insert`** — the originating link-field callback — so
that after creation the new customer is written back into the field that
triggered quick entry (e.g. Customer on a Sales Order).
- **Polls** for `frappe.ui.form.make_quick_entry` to exist, then patches it to
re-assert the override on every `Customer` invocation (defends against bundle
load-order races).
- Submits to **`ns_app.api.customer.create_customer_full`**, which creates
**Customer + Contact + Address** inside one DB transaction
(`begin`/`commit`/`rollback`).
- **ZIP autofill** via the public `api.zippopotam.us` service populates
city/state/country.
---
## 6. External dependencies
| Dependency | Used for | Notes |
|------------|----------|-------|
| **NMI Gateway** (`secure.nmi.com/api/transact.php`) | Sale + vault transactions | Requires `nmi_security_key` in `site_config.json` |
| **NMI Collect.js** (`secure.nmi.com/token/Collect.js`) | Client-side card tokenization | Tokenization key is currently hard-coded in `sales_invoice.js` |
| **api.zippopotam.us** | ZIP → city/state/country autofill | Public, unauthenticated, US only |
---
## 7. Configuration
`site_config.json`:
```json
{
"nmi_security_key": "your_nmi_security_key",
"enable_autopay_signup": 0
}
```
- `nmi_security_key`**required** for all charge and vault calls.
- `enable_autopay_signup` — feature flag. When falsy, the "Save for Auto Pay"
checkbox is ignored server-side and no vault entry is created, even if the
user checks the box.
Hard-coded values worth noting:
- `paid_to` account for card/ACH payments: **`"ENB Bank Account - NIL"`**
(company-abbreviation specific — see `create_payment_entry`).
- Collect.js tokenization key in `sales_invoice.js`.
---
## 8. Known issues / tech debt
- **Undeclared custom-field dependency.** `custom_auto_pay_*` and
`custom_send_via` on Customer are required but not shipped as fixtures.
A fresh install will fail until they are created manually.
- **Duplicate quick-entry logic.** `custom.js` and `customer_quick_entry.js`
both override `make_quick_entry`; only the latter is wired in `hooks.py`.
`custom.js` appears to be dead code.
- **Hard-coded account & keys.** `"ENB Bank Account - NIL"` and the Collect.js
tokenization key are not configurable.
- **Version drift.** `setup.py` declares `0.0.1` while `__init__.py` declares
`0.1.0`.
- **Verbose payment logging.** `payments.py` writes request/response snippets
via `frappe.log_error` as a debug channel; ensure no PII/PAN leakage and
consider a proper logger + log level.
- **Webhook trust.** `crystalclear_webhook` is `allow_guest=True` and does not
verify a signature/shared secret before creating Payment Entries.
---
## 9. Security model
- Card numbers and CVV are entered into **NMI-hosted iframes** (Collect.js) and
never touch ERPNext's DOM or backend — only a single-use `payment_token`
does. This keeps PCI scope minimal.
- ERPNext stores only the **vault ID**, never card data.
- The `nmi_security_key` lives in `site_config.json` (server-side only).
- All gateway calls are HTTPS.
See [SECURITY_NOTES.md](./SECURITY_NOTES.md) for hardening recommendations.

135
docs/CUSTOMER_STATEMENTS.md Normal file
View File

@@ -0,0 +1,135 @@
# Customer Statements & Late Payment Fees
> Branch: `feature/customer-statements`
Generates printable, **one-page-per-customer** account statements — formatted to
fit a standard #9 (9x4) **double-window envelope** — for customers with overdue
invoices, and (optionally) bills a **late-payment fee** that posts to the ledger
and is collectible through the app's existing payment flow.
---
## 1. What it does
From either the **Customer list** or a **Customer form**, a user can generate
account statements:
1. **Pick customers.** On the list, *Generate Statements* opens a dialog listing
every customer with overdue invoices (overdue count, max days overdue, total
outstanding) with select-all. On a Customer form, *Generate Statement* targets
that one customer.
2. **Choose whether to bill a late fee** via a checkbox in the popup
(*Generate late payment fee*, on by default).
3. **Get a printable report.** A new browser tab opens with one statement per
page — each showing the customer's open invoices, aging buckets
(Current / 130 / 3160 / 6190 / 90+), and a **Total Due**. The customer and
company (return) addresses sit in the two envelope-window positions.
Each generation is recorded on the customer's timeline as an audit-trail entry.
---
## 2. Late payment fees
### How the fee is calculated
Interest uses ERPNext's own Dunning formula:
```
fee = Σ(invoice.outstanding × rate_of_interest/100/365 × days_overdue) + dunning_fee
```
### Where the settings live — ERPNext **Dunning Type**
All fee configuration comes from the existing **Dunning Type** doctype
(Accounting ▸ Dunning Type). Nothing is auto-created; generation stops with a
clear error until it is configured. The default (`is_default`) Dunning Type for
the company is used. Fields consumed:
| Dunning Type field | Purpose |
|--------------------|---------|
| `rate_of_interest` | Annual interest rate (%) |
| `dunning_fee` | Flat fee added per statement |
| `income_account` | Credited when the fee is billed |
| `cost_center` | Cost center for the fee line (falls back to company default) |
| `custom_late_fee_item` | **Late Fee Item** — the Item used to bill the fee (custom field added by this app) |
### How the fee posts, and how it gets paid
The fee is billed as a **submitted Sales Invoice** (item → the Dunning Type
income account). This is deliberate: because it is a real Sales Invoice it
- increases the customer's receivable balance immediately, and
- appears in `get_unpaid_invoices` and is charged/settled automatically by the
app's existing payment paths (**Run Payment / AutoPay / multi-invoice** →
`create_payment_entry`), which allocate against Sales Invoices.
A bare Journal Entry (or an ERPNext Dunning document) would raise the balance but
sit **uncollectible** by those flows — hence the Sales Invoice.
### Fee invoice specifics
- **Dedicated naming series `LPF-.YYYY.-`** (e.g. `LPF-2026-00001`) so late-fee
invoices are easy to spot and filter. Registered on Sales Invoice's
`naming_series` via `after_migrate`.
- **Never taxed.** A single zero-amount "Actual" tax line keeps ERPNext from
auto-applying company/item tax templates, so the invoice total equals the
computed fee exactly and nothing extra hits the ledger.
- **Idempotent** — at most one fee invoice per customer / company / calendar
month. Prior fee invoices are excluded from the interest base (no fee-on-fee).
On the statement the fee shows as a normal invoice line tagged **“late fee”**,
folded into a single **Total Due** that equals the customer's balance.
---
## 3. Configuration / prerequisites
1. **Migrate** the app (`bench --site <site> migrate`) — creates the
`Late Fee Item` custom field on Dunning Type and registers the `LPF-` series.
2. Create an **Item** to represent the fee (a non-stock sales item, e.g.
"Late Payment Fee").
3. Create/complete a **Dunning Type** for the company with: rate of interest,
dunning fee, **income account**, and the **Late Fee Item**. Mark it default.
If any of these is missing, statement generation throws a clear, actionable
error and posts nothing.
---
## 4. Audit trail
Each generated statement adds an *Info* comment to the customer's timeline, e.g.
> Statement generated — Total Due $557.17 (late fee invoice LPF-2026-00001).
The note reflects the outcome: the fee invoice raised, *no late fee*, or
*late fee skipped* (when the fee checkbox was cleared). It is attributed to the
generating user.
---
## 5. Files
| File | Role |
|------|------|
| `ns_app/api/statements.py` | Overdue-customer query, statement builder, printable HTML, late-fee billing (Sales Invoice), audit-trail entry |
| `ns_app/templates/statements/customer_statement.html` | Jinja template for one customer page (envelope windows + aging table) |
| `ns_app/public/js/customer_statements.js` | List action + Customer-form button + selection/fee-toggle popups (loaded globally) |
| `ns_app/setup.py` | `after_migrate`: creates the Late Fee Item custom field, registers the `LPF-` naming series |
| `ns_app/hooks.py` | Wires the JS (`app_include_js`) and `after_migrate` |
### Server API (`ns_app.api.statements`)
- `get_customers_with_overdue_invoices()` — customers with overdue invoices.
- `generate_statements(customers, skip_late_fee=0)` — bills fees (unless skipped),
renders the printable HTML, records the audit entry. Returns
`{html, rendered, skipped}`.
- `get_statement_data(customer)` — the per-customer statement data (internal).
Access is restricted to System Manager, Sales User/Manager, Accounts
User/Manager.
---
## 6. Notes / non-goals
- No persisted "Statement" doctype — statements are generated on demand.
- No email/fax delivery — print only.
- Whether the fee should be taxed and the interest rate/fee amounts are business
settings, controlled entirely through Dunning Type.

View File

@@ -1,217 +0,0 @@
# 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.

View File

@@ -1,30 +0,0 @@
# NS App — Documentation
Reference documentation for the **NS App** ERPNext/Frappe custom application
(NS Innovations). This app extends the Sales workflow with embedded card
payments, AutoPay vaulting, multi-invoice settlement, guided customer
onboarding, and branded print formats.
## Contents
| Document | What's in it |
|----------|--------------|
| [ARCHITECTURE.md](./ARCHITECTURE.md) | What the app does, component map, payment flow diagram, data model, external dependencies, configuration, and known tech debt. |
| [DESIGN_SPEC.md](./DESIGN_SPEC.md) | Goals/non-goals, personas, functional requirements, server API contracts, design constraints, failure modes, and acceptance criteria. |
| [SECURITY_NOTES.md](./SECURITY_NOTES.md) | Current security posture and a prioritized hardening list. |
## Quick orientation
- **Backend:** `ns_app/api/payments.py` (NMI gateway), `ns_app/api/customer.py`
(atomic customer creation).
- **Frontend:** `ns_app/public/js/sales_invoice.js` (payment UI),
`ns_app/public/js/customer_quick_entry.js` (customer quick entry).
- **Wiring:** `ns_app/hooks.py`.
- **Print formats:** `ns_app/print_formats/` (shipped as fixtures).
## Related
- Root [README.md](../README.md) — install & configuration guide.
- `ns_app/api/payment_flow_documentation.md` — original narrative walkthrough of
the payment flow (kept for historical context; superseded by
[ARCHITECTURE.md](./ARCHITECTURE.md) §3).

View File

@@ -1,33 +0,0 @@
# NS App — Security Notes & Hardening
Companion to [ARCHITECTURE.md](./ARCHITECTURE.md) §9 and
[DESIGN_SPEC.md](./DESIGN_SPEC.md) §7.
## Current posture (as implemented)
- **PCI scope minimized.** Card number, expiry, and CVV are entered into
NMI-hosted Collect.js iframes. ERPNext receives only a single-use
`payment_token`. Raw card data never reaches the browser JS context or the
server.
- **No card storage.** Only the NMI Customer Vault ID is persisted on the
Customer (`custom_auto_pay_id`).
- **Secret handling.** `nmi_security_key` is read from `site_config.json`
(server-side) and sent only in server→NMI requests.
- **Transport.** All gateway calls use HTTPS to `secure.nmi.com`.
## Gaps & recommended hardening
| # | Issue | Recommendation |
|---|-------|----------------|
| 1 | **Unauthenticated webhook.** `crystalclear_webhook` is `allow_guest=True` and creates Payment Entries from any POST whose `orderid` matches an invoice. | Require a shared secret / HMAC signature; verify before writing. Optionally allow-list source IPs. |
| 2 | **Debug logging of gateway payloads.** `payments.py` writes request/response snippets via `frappe.log_error`. | Confirm no PAN/PII is ever logged; use a dedicated logger at an appropriate level; consider truncation/redaction and log retention limits. |
| 3 | **Hard-coded tokenization key** in `sales_invoice.js`. | Move to a server-provided value / site config; makes key rotation and per-environment keys possible. |
| 4 | **Hard-coded ledger account** `"ENB Bank Account - NIL"`. | Make company-aware via config or a Company-level custom field. |
| 5 | **Permissions.** Payment endpoints are whitelisted to any logged-in user. | Add role checks (as `create_customer_full` does with `frappe.only_for`) and/or rate limiting. |
| 6 | **`ignore_permissions=True`** on Payment Entry and Customer writes. | Acceptable for a system flow, but document the trust boundary and ensure the whitelisted entry points are themselves access-controlled. |
## Operational reminders
- Keep `enable_autopay_signup = 0` in production until vaulting is fully tested.
- Never commit `site_config.json` or the NMI security key to version control.
- Rotate the NMI security key and Collect.js key on any suspected exposure.

498
ns_app/api/statements.py Normal file
View File

@@ -0,0 +1,498 @@
"""Customer account statements.
Generates printable, one-customer-per-page account statements for customers with
overdue invoices, formatted for a standard double-window envelope. Statement
generation also books a late-payment fee to the ledger (see the late-fee helpers
added alongside the generator).
"""
import json
import frappe
from frappe import _
from frappe.contacts.doctype.address.address import get_address_display, get_default_address
from frappe.utils import flt, fmt_money, getdate, nowdate
# Dedicated naming series so late-fee invoices are easy to spot and filter.
LATE_FEE_NAMING_SERIES = "LPF-.YYYY.-"
# Roles allowed to run collections/statement actions.
ALLOWED_ROLES = [
"System Manager",
"Sales User",
"Sales Manager",
"Accounts User",
"Accounts Manager",
]
@frappe.whitelist()
def get_customers_with_overdue_invoices():
"""Return one row per customer that has at least one overdue Sales Invoice.
A Sales Invoice is overdue when it is submitted, still has an outstanding
balance, and its due date is in the past.
"""
frappe.only_for(ALLOWED_ROLES)
today = nowdate()
rows = frappe.get_all(
"Sales Invoice",
filters={
"docstatus": 1,
"outstanding_amount": [">", 0],
"due_date": ["<", today],
},
fields=[
"customer",
"customer_name",
"count(name) as overdue_count",
"sum(outstanding_amount) as total_outstanding",
"min(due_date) as oldest_due_date",
],
group_by="customer, customer_name",
order_by="total_outstanding desc",
)
for row in rows:
row["max_days_overdue"] = (
(getdate(today) - getdate(row.oldest_due_date)).days
if row.oldest_due_date
else 0
)
return rows
def _aging_bucket(days_overdue):
"""Map days-overdue to a standard aging bucket label."""
if days_overdue <= 0:
return "Current"
if days_overdue <= 30:
return "1-30"
if days_overdue <= 60:
return "31-60"
if days_overdue <= 90:
return "61-90"
return "90+"
def _get_outstanding_invoices(customer):
"""Return all open (submitted, unpaid) Sales Invoices for a customer.
The statement lists the full open balance, so this includes not-yet-due
invoices; each row is annotated with days overdue, an overdue flag, and its
aging bucket.
"""
today = getdate(nowdate())
invoices = frappe.get_all(
"Sales Invoice",
filters={
"customer": customer,
"docstatus": 1,
"outstanding_amount": [">", 0],
},
fields=[
"name",
"posting_date",
"due_date",
"outstanding_amount",
"grand_total",
"company",
],
order_by="due_date asc",
)
for inv in invoices:
due = getdate(inv.due_date) if inv.due_date else None
days = (today - due).days if due else 0
inv["days_overdue"] = days if days > 0 else 0
inv["is_overdue"] = days > 0
inv["aging_bucket"] = _aging_bucket(inv["days_overdue"])
return invoices
def _address_display(doctype, name):
"""Return the formatted (HTML) default address for a party, or ''."""
address_name = get_default_address(doctype, name)
if not address_name:
return ""
return get_address_display(frappe.get_doc("Address", address_name).as_dict()) or ""
def _resolve_company(invoices):
"""Pick the company for the statement header/return address."""
if invoices:
return invoices[0].company
return frappe.defaults.get_user_default("Company") or frappe.db.get_single_value(
"Global Defaults", "default_company"
)
def get_statement_data(customer, invoices=None):
"""Assemble everything the statement template needs for one customer.
Late-fee charges are billed as Sales Invoices, so they appear in the invoice
list like any other open item (flagged `is_late_fee`); there is no separate
fee total to add.
"""
cust = frappe.get_doc("Customer", customer)
if invoices is None:
invoices = _get_outstanding_invoices(customer)
fee_names = _late_fee_invoice_names(customer)
company = _resolve_company(invoices)
company_doc = frappe.get_doc("Company", company) if company else None
aging = {"Current": 0.0, "1-30": 0.0, "31-60": 0.0, "61-90": 0.0, "90+": 0.0}
total_due = 0.0
for inv in invoices:
inv["is_late_fee"] = inv["name"] in fee_names
aging[inv["aging_bucket"]] += flt(inv["outstanding_amount"])
total_due += flt(inv["outstanding_amount"])
return {
"customer": cust.name,
"customer_name": cust.customer_name,
"customer_address": _address_display("Customer", cust.name),
"company": company,
"company_name": company_doc.company_name if company_doc else "",
"return_address": _address_display("Company", company) if company else "",
"currency": (company_doc.default_currency if company_doc else None)
or frappe.db.get_single_value("Global Defaults", "default_currency"),
"invoices": invoices,
"aging": aging,
"total_due": total_due,
"statement_date": nowdate(),
}
def _render_page(data):
path = frappe.get_app_path(
"ns_app", "templates", "statements", "customer_statement.html"
)
with open(path) as f:
template = f.read()
return frappe.render_template(template, {"s": data})
def _wrap_document(pages):
"""Wrap rendered per-customer pages in a printable HTML document."""
body = "\n".join(pages)
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Customer Statements</title>
<style>
@page {{ size: Letter; margin: 0; }}
* {{ box-sizing: border-box; }}
body {{ margin: 0; font-family: Helvetica, Arial, sans-serif; color: #333; }}
.toolbar {{ text-align: center; padding: 12px; background: #f5f5f5; }}
.toolbar button {{ font-size: 14px; padding: 8px 20px; cursor: pointer; }}
.statement-page {{
position: relative;
width: 8.5in;
min-height: 11in;
margin: 0 auto;
padding: 0;
page-break-after: always;
overflow: hidden;
}}
.statement-page:last-of-type {{ page-break-after: auto; }}
/* Window positions are field-tuned to the #9 (9x4) double-window envelope
(verified against a printed proof). */
.return-window {{
position: absolute; top: 0.8in; left: 0.6in;
width: 3.5in; font-size: 11px; line-height: 1.3;
}}
.doc-header {{
position: absolute; top: 0.8in; right: 0.6in;
width: 3in; text-align: right; font-size: 13px; line-height: 1.5;
}}
.doc-header .doc-title {{ font-size: 20px; font-weight: bold; letter-spacing: 1px; }}
.recipient-window {{
position: absolute; top: 2.5in; left: 1.125in;
width: 4.5in; height: 1.25in; font-size: 15px; line-height: 1.15em;
overflow: hidden;
}}
.statement-body {{ padding: 3.9in 0.6in 0.6in 0.6in; }}
.intro {{ font-size: 12px; margin-bottom: 12px; }}
table.items, table.aging {{ width: 100%; border-collapse: collapse; }}
table.items th, table.items td,
table.aging th, table.aging td {{
border: 1px solid #ccc; padding: 6px; font-size: 13px;
}}
table.items th, table.aging th {{ background: #f5f5f5; text-align: left; }}
.c {{ text-align: center; }}
.r {{ text-align: right; }}
tr.overdue td {{ color: #c62828; }}
.tag {{
display: inline-block; font-size: 10px; font-weight: bold; color: #fff;
background: #c62828; border-radius: 3px; padding: 1px 5px; vertical-align: middle;
}}
.totals {{ width: 45%; margin: 12px 0 12px auto; font-size: 14px; }}
.totals p {{ display: flex; justify-content: space-between; margin: 4px 0; }}
.totals p.grand {{
border-top: 2px solid #333; padding-top: 6px; font-weight: bold; font-size: 16px;
}}
table.aging {{ margin-top: 8px; }}
.footer {{
margin-top: 24px; font-size: 10px; color: #777; text-align: center;
white-space: pre-line;
}}
@media print {{ .toolbar {{ display: none; }} }}
</style>
</head>
<body>
<div class="toolbar">
<button onclick="window.print()">Print Statements</button>
</div>
{body}
</body>
</html>"""
# ── Late-payment fee (billed as a Sales Invoice on generation) ───────────────
#
# Fee schedule/amounts come from ERPNext's existing **Dunning Type** settings
# (rate_of_interest is a yearly %, plus a flat dunning_fee), editable in the
# desk. Interest is computed with ERPNext's own Dunning formula so the numbers
# match a Dunning document. The fee is billed as a submitted **Sales Invoice**
# (item -> Dunning Type income account) so it both hits the ledger and is
# collectible by the app's existing payment flow (Run Payment / AutoPay /
# multi-invoice), which settles Sales Invoices.
DUNNING_TYPE_FIELDS = [
"name",
"rate_of_interest",
"dunning_fee",
"income_account",
"cost_center",
"company",
"custom_late_fee_item",
]
def _get_fee_settings(company):
"""Resolve the Dunning Type used for late fees for a company.
Nothing is auto-created: the user must configure a Dunning Type (rate of
interest, fee, income account) for the company in ERPNext. If none exists we
stop with a clear, actionable error rather than inventing default values.
"""
def first(filters):
rows = frappe.get_all(
"Dunning Type", filters=filters, fields=DUNNING_TYPE_FIELDS, limit=1
)
return rows[0] if rows else None
dt = first({"company": company, "is_default": 1}) or first({"company": company})
if not dt:
frappe.throw(
_(
"No Dunning Type is configured for {0}. Create one under "
"Accounting > Dunning Type — set the rate of interest, dunning "
"fee, and income account — before generating statements."
).format(company)
)
return dt
def _late_fee_period():
"""Statement period key used for idempotency (one fee per calendar month)."""
return getdate(nowdate()).strftime("%Y-%m")
def _get_fee_invoices(customer, company, fee_item):
"""Return submitted late-fee Sales Invoices for a customer (by fee item)."""
if not fee_item:
return []
return frappe.db.sql(
"""
select si.name, si.posting_date
from `tabSales Invoice` si
inner join `tabSales Invoice Item` sii on sii.parent = si.name
where si.customer = %s and si.company = %s and si.docstatus = 1
and sii.item_code = %s
""",
(customer, company, fee_item),
as_dict=True,
)
def _post_late_fee_invoice(customer, company, overdue_invoices, period):
"""Bill a late fee as a submitted Sales Invoice (idempotent per month).
Returns the fee invoice name, or None if nothing was billed.
"""
if not overdue_invoices:
return None
settings = _get_fee_settings(company)
fee_item = settings.get("custom_late_fee_item")
if not fee_item:
frappe.throw(
_("Set a Late Fee Item on Dunning Type {0} before generating statements.").format(
settings.name
)
)
if not settings.income_account:
frappe.throw(
_("Set an Income Account on Dunning Type {0} before generating statements.").format(
settings.name
)
)
fee_invoices = _get_fee_invoices(customer, company, fee_item)
# Idempotency: at most one fee invoice per (customer, company, month).
month_start = getdate(period + "-01")
for fi in fee_invoices:
if getdate(fi.posting_date) >= month_start:
return fi.name
# Interest on overdue balances, excluding prior fee invoices (no fee-on-fee).
prior_fee_names = {fi.name for fi in fee_invoices}
daily_interest = flt(settings.rate_of_interest) / 100.0 / 365.0
interest = sum(
flt(inv["outstanding_amount"]) * daily_interest * inv["days_overdue"]
for inv in overdue_invoices
if inv["name"] not in prior_fee_names
)
fee = round(interest + flt(settings.dunning_fee), 2)
if fee <= 0:
return None
cost_center = settings.cost_center or frappe.get_cached_value(
"Company", company, "cost_center"
)
si = frappe.new_doc("Sales Invoice")
si.naming_series = LATE_FEE_NAMING_SERIES
si.customer = customer
si.company = company
si.posting_date = nowdate()
si.due_date = nowdate()
si.append(
"items",
{
"item_code": fee_item,
"qty": 1,
"rate": fee,
"income_account": settings.income_account,
"cost_center": cost_center,
"description": _("Late payment fee for statement period {0}").format(period),
},
)
# Late fees are not taxed. A single zero "Actual" tax line keeps the taxes
# table non-empty, which stops ERPNext from auto-applying the company or
# item tax templates; being zero it posts nothing to the ledger.
si.taxes_and_charges = ""
si.append(
"taxes",
{
"charge_type": "Actual",
"account_head": settings.income_account,
"description": _("Late fees are not taxed"),
"tax_amount": 0,
"rate": 0,
},
)
si.insert(ignore_permissions=True)
si.submit()
frappe.db.commit()
return si.name
def _late_fee_invoice_names(customer):
"""Names of the customer's submitted late-fee Sales Invoices (any company)."""
fee_items = [
d.custom_late_fee_item
for d in frappe.get_all("Dunning Type", fields=["custom_late_fee_item"])
if d.custom_late_fee_item
]
if not fee_items:
return set()
rows = frappe.db.sql(
"""
select distinct sii.parent
from `tabSales Invoice Item` sii
inner join `tabSales Invoice` si on si.name = sii.parent
where si.customer = %s and si.docstatus = 1 and sii.item_code in %s
""",
(customer, tuple(fee_items)),
as_dict=True,
)
return {r.parent for r in rows}
def _record_statement_activity(customer, data, fee_invoice_names, skip_late_fee):
"""Log statement generation on the customer's timeline (audit trail)."""
total = fmt_money(data["total_due"], currency=data["currency"])
if skip_late_fee:
fee_note = _("late fee skipped")
elif fee_invoice_names:
fee_note = _("late fee invoice {0}").format(", ".join(fee_invoice_names))
else:
fee_note = _("no late fee")
frappe.get_doc("Customer", customer).add_comment(
"Info", _("Statement generated — Total Due {0} ({1}).").format(total, fee_note)
)
@frappe.whitelist()
def generate_statements(customers, skip_late_fee=0):
"""Render printable statements (one page per customer) for the selection.
Side effect (unless `skip_late_fee`): a late-payment fee is billed as a
Sales Invoice (once per customer per month) for each customer with overdue
invoices. Each generation is recorded on the customer's timeline.
`customers` may arrive as a JSON-encoded list from the client.
"""
frappe.only_for(ALLOWED_ROLES)
if isinstance(customers, str):
try:
customers = json.loads(customers)
except (ValueError, TypeError):
customers = [customers]
if not customers:
frappe.throw(_("No customers selected"))
skip_late_fee = int(skip_late_fee or 0)
period = _late_fee_period()
pages, rendered, skipped = [], [], []
for customer in customers:
invoices = _get_outstanding_invoices(customer)
if not invoices:
skipped.append(customer)
continue
# Bill the late fee per company (on overdue invoices only).
fee_invoice_names = []
if not skip_late_fee:
overdue_by_company = {}
for inv in invoices:
if inv["is_overdue"]:
overdue_by_company.setdefault(inv["company"], []).append(inv)
for comp, invs in overdue_by_company.items():
name = _post_late_fee_invoice(customer, comp, invs, period)
if name:
fee_invoice_names.append(name)
# Re-fetch so the statement includes the freshly billed fee invoice(s).
data = get_statement_data(customer)
pages.append(_render_page(data))
rendered.append(customer)
_record_statement_activity(customer, data, fee_invoice_names, skip_late_fee)
if not pages:
frappe.throw(_("None of the selected customers have an outstanding balance."))
return {"html": _wrap_document(pages), "rendered": rendered, "skipped": skipped}

View File

@@ -7,7 +7,8 @@ app_license = "MIT"
# Load on every page
app_include_js = [
"/assets/ns_app/js/customer_quick_entry.js"
"/assets/ns_app/js/customer_quick_entry.js",
"/assets/ns_app/js/customer_statements.js"
]
# Load on Sales Invoice form
@@ -15,6 +16,14 @@ doctype_js = {
"Sales Invoice": "public/js/sales_invoice.js"
}
# Load on Customer list view (merges the "Generate Statements" action)
doctype_list_js = {
"Customer": "public/js/customer_list.js"
}
# Ensure custom fields exist after every migrate
after_migrate = "ns_app.setup.after_migrate"
# Fixtures tracked in Git
fixtures = [
{

1
ns_app/modules.txt Normal file
View File

@@ -0,0 +1 @@
NS App

View File

0
ns_app/patches.txt Normal file
View File

View File

@@ -0,0 +1,20 @@
// Customer list action: "Generate Statements". Registered as a doctype_list_js
// so it loads alongside ERPNext's own Customer list settings (in app order,
// after them). We MERGE into listview_settings — preserving any existing
// onload / add_fields — instead of reassigning the object, which would clobber
// ERPNext's settings (and be clobbered by them). The shared generate/print
// helpers live on `ns_statements` (public/js/customer_statements.js).
frappe.listview_settings["Customer"] = frappe.listview_settings["Customer"] || {};
(function () {
const settings = frappe.listview_settings["Customer"];
const original_onload = settings.onload;
settings.onload = function (listview) {
if (original_onload) original_onload(listview);
listview.page.add_inner_button(__("Generate Statements"), () => {
ns_statements.pick_and_generate();
});
};
})();

View File

@@ -0,0 +1,200 @@
// Customer Statements: generate printable, one-page-per-customer account
// statements formatted for a window envelope. Two entry points share the same
// generate/print helpers — a multi-select action on the Customer list and a
// single-customer button on the Customer form. Loaded globally so both the
// list view and the form can reach the shared `ns_statements` helpers.
frappe.provide("ns_statements");
// The Customer list button is registered separately in customer_list.js
// (a doctype_list_js) so it merges with — rather than overwrites — ERPNext's
// own listview_settings["Customer"]. Shared helpers live here on ns_statements.
// ── Entry point: Customer form ───────────────────────────────────────────────
frappe.ui.form.on("Customer", {
refresh(frm) {
if (frm.is_new()) return;
frm.add_custom_button(__("Generate Statement"), () => {
ns_statements.generate_for_customer(frm.doc.name);
});
}
});
// ── Shared: call the backend and open the printable document ─────────────────
ns_statements.run = function (customers, skip_late_fee) {
frappe.call({
method: "ns_app.api.statements.generate_statements",
args: { customers, skip_late_fee: skip_late_fee ? 1 : 0 },
freeze: true,
freeze_message: __("Generating statements..."),
callback(r) {
if (!r.message || !r.message.html) return;
ns_statements.open_print_window(r.message.html);
const skipped = (r.message.skipped || []).length;
if (skipped) {
frappe.show_alert({
message: __("Skipped {0} customer(s) with no balance.", [skipped]),
indicator: "orange"
});
}
}
});
};
ns_statements.open_print_window = function (html) {
const w = window.open("", "_blank");
if (!w) {
frappe.msgprint(__("Please allow pop-ups to view the statements."));
return;
}
w.document.open();
w.document.write(html);
w.document.close();
};
// ── List flow: pick customers with overdue invoices, then generate ───────────
ns_statements.pick_and_generate = function () {
frappe.call({
method: "ns_app.api.statements.get_customers_with_overdue_invoices",
freeze: true,
freeze_message: __("Finding customers with overdue invoices..."),
callback(r) {
const rows = r.message || [];
if (!rows.length) {
frappe.msgprint({
title: __("No Overdue Customers"),
message: __("No customers currently have overdue invoices."),
indicator: "green"
});
return;
}
ns_statements._selection_dialog(rows);
}
});
};
ns_statements._selection_dialog = function (rows) {
const uid = Date.now();
const selected = new Set(rows.map(r => r.customer)); // default: all selected
const body = rows.map(r => `
<tr>
<td class="text-center">
<input type="checkbox" class="cust-check-${uid}"
data-name="${frappe.utils.escape_html(r.customer)}" checked>
</td>
<td>${frappe.utils.escape_html(r.customer_name || r.customer)}</td>
<td class="text-center">${r.overdue_count}</td>
<td class="text-center">${r.max_days_overdue}</td>
<td class="text-right">${format_currency(r.total_outstanding)}</td>
</tr>`).join("");
const dialog = new frappe.ui.Dialog({
title: __("Generate Customer Statements"),
size: "large",
fields: [
{
fieldtype: "HTML",
fieldname: "selector",
options: `
<div style="max-height:45vh; overflow:auto;">
<table class="table table-bordered table-sm" style="font-size:13px; margin:0;">
<thead style="position:sticky; top:0; background:#f5f5f5;">
<tr>
<th style="width:36px;">
<input type="checkbox" id="sel_all_${uid}" title="${__("Select all")}" checked>
</th>
<th>${__("Customer")}</th>
<th class="text-center">${__("Overdue Invoices")}</th>
<th class="text-center">${__("Max Days Overdue")}</th>
<th class="text-right">${__("Total Outstanding")}</th>
</tr>
</thead>
<tbody id="sel_body_${uid}">${body}</tbody>
</table>
</div>
<div id="sel_count_${uid}" style="margin-top:8px; font-weight:bold;"></div>`
},
{
fieldtype: "Check",
fieldname: "generate_late_fee",
label: __("Generate late payment fee"),
default: 1,
description: __("Bills a late-fee invoice (once per customer this month) for overdue balances.")
}
],
primary_action_label: __("Generate Statements"),
primary_action() {
const customers = [...selected];
if (!customers.length) {
frappe.msgprint(__("Select at least one customer."));
return;
}
const gen_fee = dialog.get_value("generate_late_fee");
const proceed = () => {
dialog.hide();
ns_statements.run(customers, !gen_fee);
};
if (gen_fee) {
frappe.confirm(
__("Generate statements for {0} customer(s)? A late-fee invoice will be raised (once per customer this month) for any overdue balances.", [customers.length]),
proceed
);
} else {
proceed();
}
}
});
dialog.show();
const update_count = () => {
const el = document.getElementById(`sel_count_${uid}`);
if (el) el.innerText = __("{0} of {1} selected", [selected.size, rows.length]);
};
update_count();
dialog.$wrapper.on("change", `.cust-check-${uid}`, function () {
if (this.checked) selected.add(this.dataset.name);
else selected.delete(this.dataset.name);
const all = dialog.$wrapper[0].querySelectorAll(`.cust-check-${uid}`);
const selAll = document.getElementById(`sel_all_${uid}`);
if (selAll) selAll.checked = [...all].every(c => c.checked);
update_count();
});
dialog.$wrapper.on("change", `#sel_all_${uid}`, function () {
dialog.$wrapper[0].querySelectorAll(`.cust-check-${uid}`).forEach(cb => {
cb.checked = this.checked;
if (this.checked) selected.add(cb.dataset.name);
else selected.delete(cb.dataset.name);
});
update_count();
});
};
// ── Form flow: single customer, with the same fee toggle ─────────────────────
ns_statements.generate_for_customer = function (customer) {
const d = new frappe.ui.Dialog({
title: __("Generate Statement"),
fields: [
{
fieldtype: "HTML",
options: `<p>${__("Generate an account statement for <b>{0}</b>.", [frappe.utils.escape_html(customer)])}</p>`
},
{
fieldtype: "Check",
fieldname: "generate_late_fee",
label: __("Generate late payment fee"),
default: 1,
description: __("Bills a late-fee invoice (once this month) for overdue balances.")
}
],
primary_action_label: __("Generate"),
primary_action(values) {
d.hide();
ns_statements.run([customer], !values.generate_late_fee);
}
});
d.show();
};

46
ns_app/setup.py Normal file
View File

@@ -0,0 +1,46 @@
"""App setup: custom fields created/synced on migrate."""
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from ns_app.api.statements import LATE_FEE_NAMING_SERIES
# Fee schedule/amounts live on ERPNext's Dunning Type; this adds the one thing
# it lacks — the Item used to bill a late fee as a Sales Invoice.
CUSTOM_FIELDS = {
"Dunning Type": [
{
"fieldname": "custom_late_fee_item",
"label": "Late Fee Item",
"fieldtype": "Link",
"options": "Item",
"insert_after": "income_account",
"description": (
"Item used to bill a late-payment fee as a Sales Invoice when "
"customer statements are generated."
),
}
]
}
def _register_late_fee_naming_series():
"""Add the late-fee series to Sales Invoice's naming_series options."""
field = frappe.get_meta("Sales Invoice").get_field("naming_series")
options = [o for o in (field.options or "").split("\n")] if field else []
if LATE_FEE_NAMING_SERIES not in options:
options.append(LATE_FEE_NAMING_SERIES)
make_property_setter(
"Sales Invoice",
"naming_series",
"options",
"\n".join(options),
"Text",
validate_fields_for_doctype=False,
)
def after_migrate():
create_custom_fields(CUSTOM_FIELDS)
_register_late_fee_naming_series()

View File

@@ -0,0 +1,95 @@
{# One customer account statement = one printed page.
Envelope geometry (window positions in _wrap_document) is field-tuned to the
#9 (9x4) double-window envelope. Rendered via frappe.render_template with
context key `s` (see ns_app.api.statements.get_statement_data). #}
{% set fmt = frappe.utils.fmt_money %}
<div class="statement-page">
<!-- Return address (top-left envelope window) -->
<div class="return-window">
<strong>{{ s.company_name }}</strong><br>
{{ s.return_address | safe }}
</div>
<!-- Document header (top-right) -->
<div class="doc-header">
<div class="doc-title">STATEMENT</div>
<div><strong>Date:</strong> {{ frappe.utils.formatdate(s.statement_date, "MM-dd-yyyy") }}</div>
<div><strong>Account:</strong> {{ s.customer }}</div>
</div>
<!-- Recipient address (lower envelope window) -->
<div class="recipient-window">
{{ s.customer_name }}<br>
{{ s.customer_address | safe }}
</div>
<!-- Statement body (starts below the address windows) -->
<div class="statement-body">
<div class="intro">
The following is a summary of your account as of
{{ frappe.utils.formatdate(s.statement_date, "MM-dd-yyyy") }}.
Please remit payment for any past-due balance at your earliest convenience.
</div>
<table class="items">
<thead>
<tr>
<th>Invoice</th>
<th class="c">Date</th>
<th class="c">Due Date</th>
<th class="c">Days Overdue</th>
<th class="c">Aging</th>
<th class="r">Outstanding</th>
</tr>
</thead>
<tbody>
{% for inv in s.invoices %}
<tr class="{{ 'overdue' if inv.is_overdue else '' }}">
<td>{{ inv.name }}{% if inv.is_late_fee %} <span class="tag">late fee</span>{% endif %}</td>
<td class="c">{{ frappe.utils.formatdate(inv.posting_date, "MM-dd-yyyy") }}</td>
<td class="c">{{ frappe.utils.formatdate(inv.due_date, "MM-dd-yyyy") }}</td>
<td class="c">{{ inv.days_overdue if inv.days_overdue else "—" }}</td>
<td class="c">{{ inv.aging_bucket }}</td>
<td class="r">{{ fmt(inv.outstanding_amount, currency=s.currency) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Totals -->
<div class="totals">
<p class="grand"><span>Total Due:</span><span>{{ fmt(s.total_due, currency=s.currency) }}</span></p>
</div>
<!-- Aging summary -->
<table class="aging">
<thead>
<tr>
<th class="c">Current</th>
<th class="c">130</th>
<th class="c">3160</th>
<th class="c">6190</th>
<th class="c">90+</th>
</tr>
</thead>
<tbody>
<tr>
<td class="c">{{ fmt(s.aging["Current"], currency=s.currency) }}</td>
<td class="c">{{ fmt(s.aging["1-30"], currency=s.currency) }}</td>
<td class="c">{{ fmt(s.aging["31-60"], currency=s.currency) }}</td>
<td class="c">{{ fmt(s.aging["61-90"], currency=s.currency) }}</td>
<td class="c">{{ fmt(s.aging["90+"], currency=s.currency) }}</td>
</tr>
</tbody>
</table>
<div class="footer">
Prompt payment is always appreciated. We accept payments by check or over
the phone using a debit or credit card. Automatic payment setup is also
available upon request. Please contact us if payment has already been sent.
</div>
</div><!-- /statement-body -->
</div><!-- /statement-page -->