Compare commits
1 Commits
feature/cu
...
docs/app-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bb8954ff6 |
@@ -11,6 +11,13 @@ 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
|
||||
|
||||
209
docs/ARCHITECTURE.md
Normal file
209
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# 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.
|
||||
@@ -1,135 +0,0 @@
|
||||
# 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 / 1–30 / 31–60 / 61–90 / 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.
|
||||
217
docs/DESIGN_SPEC.md
Normal file
217
docs/DESIGN_SPEC.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# 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.
|
||||
30
docs/README.md
Normal file
30
docs/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 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).
|
||||
33
docs/SECURITY_NOTES.md
Normal file
33
docs/SECURITY_NOTES.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 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.
|
||||
@@ -1,498 +0,0 @@
|
||||
"""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}
|
||||
@@ -7,8 +7,7 @@ app_license = "MIT"
|
||||
|
||||
# Load on every page
|
||||
app_include_js = [
|
||||
"/assets/ns_app/js/customer_quick_entry.js",
|
||||
"/assets/ns_app/js/customer_statements.js"
|
||||
"/assets/ns_app/js/customer_quick_entry.js"
|
||||
]
|
||||
|
||||
# Load on Sales Invoice form
|
||||
@@ -16,14 +15,6 @@ 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 +0,0 @@
|
||||
NS App
|
||||
@@ -1,20 +0,0 @@
|
||||
// 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();
|
||||
});
|
||||
};
|
||||
})();
|
||||
@@ -1,200 +0,0 @@
|
||||
// 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();
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,95 +0,0 @@
|
||||
{# 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">1–30</th>
|
||||
<th class="c">31–60</th>
|
||||
<th class="c">61–90</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 -->
|
||||
Reference in New Issue
Block a user