Task #155 » EVOUCHER_PROJECT_SUMMARY.md
E-Voucher — Project Summary
What exists today in slcpl_erpnext_app, written from the current code (read on 2026-09-25).
Paths below are relative to the app folder apps/slcpl_erpnext_app/. Line counts are approximate and are only there to show whether a file is a small piece or a big one.
0. The two-minute version
What it is. E-Voucher is how staff claim money back for work expenses (taxi, stationery, courier, and so on). An employee fills in a voucher that lists each expense with a receipt. They pick an approver and send it. The approver approves or rejects it. Once a voucher is approved, accounts turn it into a Purchase Invoice and a payment in ERPNext as usual.
Who uses it.
-
Senders have the role
10A-E-Voucher Send. They create vouchers and track their own. -
Approvers have the role
10B-E-Voucher Approve/Rejectand an E-Voucher Approver record. That record sets their branch and their approval limit in rupees. They review vouchers that were sent to them. - One person can hold both roles.
Where it runs. There is a mobile-friendly web app at /frontend (Vue + frappe-ui). It can be installed on a phone like an app. The data itself is ordinary ERPNext data: the E-Voucher DocType, a workflow, and whitelisted Python APIs. The older Desk screens (the E-Voucher form and the e-voucher-wizard Desk page) still exist and share the same backend.
The two things that keep it safe:
- Ownership and assignment checks. A normal user can only see vouchers they created or vouchers where they are the assigned approver. The database query and the per-document permission hook both enforce this, so the app can't bypass it.
- Approver checks, done on the server every time. A voucher can only be approved by its assigned approver, and only if their limit covers the total. The total is recalculated from the expense rows. The approver can't be the voucher's creator or the claimant. These checks run again at save, send, approve and reject. The screen's filtering is only a convenience.
1. Frontend — page by page
The single-page app (SPA) lives in frontend/src/. yarn build compiles it into slcpl_erpnext_app/public/frontend/. Frappe serves it at /frontend/....
How the pieces talk to each other (state, in plain terms)
-
No central store. There's no Vuex or Pinia. The app uses a few small shared JavaScript objects, one per kind of data, in
frontend/src/data/. Any screen can import and read them. -
Every request goes through one helper. That helper is
utils/api.js, with two functions:-
apiGetfor reads. It sends a GET request, so no CSRF token is needed. -
apiPostfor the few real writes. It sends a POST with the CSRF token that Frappe put into the page.
-
- Lists are kept in memory. Each list (My Vouchers, To Review) is one shared object. Moving between screens keeps its filters and results. A sequence number throws away late replies, so while you type a search, a slow older reply can't overwrite a newer one.
- The wizard keeps its own state. Everything you type during voucher creation lives inside that page until you press Send for Approval. At that point it's sent in one POST. Nothing is saved part-way through.
- The detail screen reuses list data for display only. If the list already loaded a voucher's date and branch, the detail screen borrows them. Everything else it fetches itself.
-
When lists refresh: each list and its count tiles load the first time you open them. After that they re-query the server when you change a filter or press Retry, and also:
- right after you send, approve or reject a voucher (
refreshVoucherData()indata/voucherLists.js, called from the wizard and the detail screen); - when a new E-Voucher notification arrives, which means someone else sent, approved or rejected one of yours.
Only lists already in memory are re-queried, through the same server methods as the first load. There's no extra timer: the notification check is the existing one in §1.7.
- right after you send, approve or reject a voucher (
Routes at a glance (frontend/src/router.js, ~80 lines)
| URL | Screen | Who sees it |
|---|---|---|
/frontend/ |
My Vouchers | Senders |
/frontend/review |
Vouchers to Review | Approvers |
/frontend/voucher/new |
Creation wizard | Senders |
/frontend/voucher/<name> |
Voucher detail / approve-reject | Owner, assigned approver, or System Manager |
/frontend/profile |
Profile menu | Everyone |
/frontend/profile/details |
Edit my details | Everyone |
/frontend/login |
Login | Logged-out users |
A check runs before every route: if you're not logged in, you go to Login. A user with only one of the two roles is sent straight to the list they have.
1.1 Login
What you see: an email/username and password form.
What it calls: Frappe's standard login. After a successful login (or logout), the app does a full page reload on purpose. The reload gets a fresh CSRF token for the new session and clears anything left over from the previous user.
Files: pages/Login.vue (~110 lines), data/session.js (~60).
1.2 My Vouchers (sender's home)
What you see:
- Four count tiles: Total, Approved, Pending, Rejected.
- A search box for the voucher ID.
- Status chips: All / Draft / Pending / Approved / Rejected.
- A from/to date range.
- The list itself: a table on desktop, cards on a phone. Each row shows voucher no., expense type, branch, amount, status and date.
- Pages of 20 rows.
- A New Voucher button.
Clicking a row opens the voucher.
What it loads:
-
e_voucher_dashboard_api.get_dashboard_context: which roles you have. This decides which lists and nav items appear. -
e_voucher_dashboard_api.get_voucher_stats(kind="mine"): the four counts. -
e_voucher_dashboard_api.get_my_vouchers(search, status, from_date, to_date): the rows. All filtering happens on the server. The search box waits 300 ms after you stop typing; chips and dates apply at once.
Files:
-
pages/Home.vue(~115 lines), shared with the review list. -
components/StatTiles.vue(~70) -
components/VoucherListPanel.vue(~175) -
components/VoucherFilters.vue(~110) -
components/VoucherList.vue(~105) -
data/voucherLists.js(~70),data/stats.js(~30),data/dashboard.js(~30)
1.3 Vouchers to Review (approver's home)
What you see: the same layout as My Vouchers.
- The tiles are "Total Assigned", "Approved", "Awaiting You" and "Rejected".
- There's only a search box, with no status or date filters.
- An empty list says "All caught up".
What it loads:
-
get_voucher_stats(kind="review"). "Awaiting You" is simply the length of the review list, so the tile always matches the list. -
get_vouchers_to_review(search). The server returns only vouchers that are pending and that you are genuinely allowed to decide on right now (see §3.4).
Files: the same as My Vouchers. Home.vue switches mode based on the URL.
1.4 New Voucher — the creation wizard
The biggest file in the app: pages/NewVoucher.vue (~1,090 lines). Its thin API bindings are in data/evoucherWizard.js (~65). A step bar runs along the top. Back and Next (or Send) buttons stay pinned to the bottom of the screen.
| Step | What you do | What it calls |
|---|---|---|
| 1. Pre-flight | Check your Company, Branch and today's Date. If you may book in more than one branch, pick it here (changing it later clears the allocation, rows and approver). With one branch it's set for you. You're blocked here only if you have no branch to book in, no company default, or can't create vouchers. |
get_bootstrap, set_session_branch
|
| 2. Expense Type | Pick Administrative, Operational Non-Reimbursable or Operational Reimbursable. Changing the type later clears your expense rows, after a confirmation. | — (the list came with bootstrap) |
| 3. Claimant | Pick the Employee (a Supplier in the "Employee" group) and their address. You can also tick "Third-party supplier involved" and pick that supplier and address. | Link searches via frappe.client.get_list; address_query via frappe.desk.search.search_link; get_address_preview
|
| 4. Allocation | Pick the Administrative Log (Administrative type) or the Project (Operational types). There's an optional Transport / Freight Approval. A small card previews the chosen record. | get_allocation_preview |
| 5. Expenses | Add one row per expense: date, item, amount, admin log or project, from/to date and location when the item needs them, description, and a receipt file. A running total is shown underneath. |
get_item_evoucher_config (fills in the item name/UOM and says whether dates or locations are needed); Frappe's file upload (files are uploaded as private) |
| 6. Approver & Review | A read-only summary, the items list with total, and the approver dropdown. Then Send for Approval. |
get_eligible_approvers, then create_e_voucher (action send) |
The Next button unlocks only when the step is complete. For example, every expense row has to be complete before you can leave step 5.
After sending, a "Sent for approval" screen appears with Open Voucher, Create Another and Back to List.
Double-click safety: the wizard makes a random request_id when it opens. If Send is pressed twice, or retried after a network error, the server returns the same voucher instead of creating a second one.
The approver dropdown and the collapsible expense rows are described in §2.2.
1.5 Voucher detail and approve/reject
File: pages/VoucherDetail.vue (~420 lines), plus components/VoucherTimeline.vue (~105).
What you see:
- A header with the voucher number, status badge, and a line showing type, branch and date.
- Four tabs:
- Details: company, branch, employee, admin log or project, approver, and so on.
- Items: each expense row with its amount.
- Attachments: the receipts, which open in a new tab.
- Activity: a timeline of created → sent → approved or rejected, plus any Desk comments.
If you are the right approver and the voucher is pending:
- A green note says who the approver is.
- Approve and Reject buttons are pinned to the bottom.
- After you decide, a result screen shows "Approved successfully" or "Rejected".
If you can't decide on it, a blue note explains why. For example: you're not the assigned approver, it's your own voucher, or your limit is too low.
What it loads:
-
can_review_e_voucher: a quiet yes/no with a reason. - If yes:
get_e_voucher_for_review, which returns the voucher, its approver info and its files. - If no: the voucher through Frappe's standard
frappe.client.getand its files throughfrappe.client.get_list. The permission hooks in §3.4 still decide whether you may read it at all. -
get_e_voucher_timelinefor the Activity tab. - Approve/Reject calls
submit_review_decision. It sends the voucher'smodifiedtimestamp and arequest_id, so a stale page or a double-click can't apply the decision twice or apply it to a changed voucher.
1.6 Profile
-
/profile(pages/Profile.vue, ~125 lines): a menu page. It shows your avatar, name and Sender/Approver badges, then these entries:- My Profile
- Notifications: opens the notification list in a dialog.
-
Change Password: goes to Frappe's own
/update-passwordpage. - Log Out
-
/profile/details(pages/ProfileDetails.vue, ~205 lines):- You can edit first, middle and last name, mobile and phone.
- Email is shown locked.
- A read-only "Work details" card shows company, branch, your claimant (Supplier) record, and your approval limits per branch.
-
Calls:
e_voucher_profile_api.get_my_profileandupdate_my_profile.data/me.jskeeps a copy of your profile so the top bar shows your current name.
1.7 Notifications (the bell)
Files: components/NotificationBell.vue (~45), components/NotificationPanel.vue (~95), data/notifications.js (~110).
What you see:
- A bell in the top bar with an unread count.
- Opening it lists your recent E-Voucher notifications, such as "…is waiting for your approval" or "…was approved by …", each marked with an icon.
- Clicking one marks it read and opens that voucher. There's also "Mark all as read".
Where the data comes from: Frappe's standard Notification Log, filtered to your own E-Voucher entries by e_voucher_notifications.py (see §3):
- One read returns the latest 20 and the unread count together, so the list and the badge always agree.
- "Mark read" and "Mark all" go through one endpoint. "Mark all" marks every unread E-Voucher entry, not just the loaded 20, and leaves your other ERP notifications alone.
- Marking is shown immediately. A list read that was sent before the write is discarded, and a fresh read follows the write, so an older response can never bring notifications back as unread.
When it refreshes: once a minute while the tab is visible, when the window gets focus, and immediately when Frappe's realtime socket says a new notification arrived. If that refresh finds a notification it hasn't seen before, the voucher lists and tiles re-query too (§1 "When lists refresh").
2. Frontend — shared pieces
2.1 The shell (sidebar on desktop, bottom nav on phones)
components/layout/AppShell.vue (~175 lines) wraps every screen except Login.
- Desktop: a left sidebar shows the logo and nav items (My Vouchers / Vouchers to Review / Profile). You only see the lists your roles allow.
- Top bar: a breadcrumb, the notification bell, and an account menu (My Profile, Logout).
- Phones: the sidebar is replaced by a bottom navigation bar with the same items. The bottom bar is hidden on the wizard and detail screens, which have their own pinned buttons instead.
- One scroll region: the whole page never scrolls; only the middle content area does. That keeps the header and bottom bars fixed. Every route change scrolls that area back to the top.
App.vue (~30 lines) chooses between the shell and the bare Login layout. It also starts notification polling and shows the "new version" prompt.
2.2 Approver dropdown and collapsible expense rows (the latest fix)
Both are inside pages/NewVoucher.vue.
-
Approver dropdown (step 6):
- It's a single dropdown (frappe-ui's
Select). Each approver shows as an avatar and name. Approval limits are not shown. - It lists only approvers the server marked both
eligible(their limit is at least the voucher total) andenabled(their user account is active). - Nothing is pre-selected, even when only one approver qualifies.
- If your total grows after you picked someone who no longer qualifies, the choice is cleared.
- If nobody qualifies, an amber message appears instead of an empty dropdown: "No approver has a limit covering ₹X — contact your admin."
- The server checks all of this again when you send (§3.4), so the dropdown is only a convenience.
- It's a single dropdown (frappe-ui's
-
Collapsible expense rows (step 5):
- Each expense is a row that folds up. Folded, it shows the item name, "Expense N · date · description", the amount, a Complete/Incomplete badge, an expand arrow and a delete icon.
- Clicking the row opens the full set of fields.
- Rows open and close independently.
- A newly added row starts open. Adding a row folds away the rows that are already complete.
- When you come back to this step, complete rows are folded and incomplete ones are open.
- Whether a row is open is screen-only information. It's never sent to the server.
2.3 Network-error handling (B5)
Files: utils/api.js (~120), utils/errors.js (~95), data/connection.js (~55), components/ConnectionBanner.vue (~50).
Every request:
- Times out after 20 seconds.
- Failures are sorted into a few plain kinds: offline, server unreachable, session expired, server error, and rejected with a message. That last one covers validation, permission and not-found messages.
- Users only ever see a readable sentence, never raw codes or tracebacks.
When the network is the problem:
- Reads (GET) quietly retry twice, after 1.5 s and then 4 s.
- Writes (POST) never retry on their own, so nothing is submitted twice by accident.
- A banner appears under the top bar: "You're offline" or "Can't reach the server". It has a Retry button.
- Screens register how to reload themselves. Retry, or the browser coming back online, re-runs whatever failed.
- Lists that already had results keep showing them, marked "Showing last loaded results", instead of going blank.
2.4 PWA (installable app)
Installable: the app can be installed to a phone's home screen.
- The manifest is
frontend/public/manifest.webmanifest. It sets the name "E-Voucher", the blue theme, the/frontend/start and scope, and the icons.
What the service worker does (frontend/src/sw.js, ~45 lines):
- It caches only the app's own built files: JavaScript, CSS, fonts, icons and a small "You're offline" page. That's about 26 files, roughly 1.2 MB.
- It never caches anything under
/api/(all data) or the page itself. Frappe draws the page fresh for each session, with that session's login info and CSRF token. - Offline, data requests fail exactly as they would without a service worker, so the B5 banner still works.
- If the page itself can't load, the cached offline page is shown instead of the browser's error page.
Why it needs server code: a service worker only controls pages at or below its own URL, and Frappe won't serve a .js file from /frontend/. So a small server piece, slcpl_erpnext_app/slcpl_erpnext_app/frontend_sw.py (~50 lines, registered as page_renderer in hooks.py), serves the built worker at /frontend/sw.js.
Updates (frontend/src/pwa.js, ~35 lines, and components/UpdatePrompt.vue, ~30):
- When a new build is deployed, a "new version available" prompt appears.
- The app switches over only after the user clicks Refresh. There's no silent update.
- It checks for updates hourly and whenever the app comes back to the foreground.
Build settings are in frontend/vite.config.js: the PWA plugin and the rule of what gets cached.
3. Backend
3.1 Where the backend code lives
File (under slcpl_erpnext_app/slcpl_erpnext_app/) |
Size | What it is |
|---|---|---|
doctype/e_voucher/e_voucher.py |
~505 lines | The E-Voucher document class: validation, the approval rule, the permission hooks, Purchase Invoice / Payment Entry creation |
page/e_voucher_wizard/api.py |
~625 lines | The wizard and review APIs. The only code that creates a voucher. Shared by the SPA and the older Desk wizard page (e_voucher_wizard.js, ~3,100 lines) |
e_voucher_dashboard_api.py |
~315 lines | Read-only list, stats and timeline APIs for the SPA |
e_voucher_profile_api.py |
~100 lines | My Profile read and update |
e_voucher_notifications.py |
~80 lines | Writes in-app notifications when a voucher's workflow state changes |
frontend_sw.py |
~50 lines | Serves /frontend/sw.js
|
../www/frontend.py |
~25 lines | Renders the SPA page and adds the CSRF token |
doctype/e_voucher/test_e_voucher.py |
~665 lines | 56 server tests |
Hooks that matter (hooks.py):
-
permission_query_conditionsandhas_permissionfor E-Voucher (§3.4). -
doc_events→ E-Voucheron_change→ notifications. -
website_route_rules:/frontend/<anything>→ the SPA page. -
page_renderer→ the service worker. - Fixtures ship the roles, the E-Voucher workflow, and the Purchase Types.
3.2 The E-Voucher data model
E-Voucher is submittable and named like EVCHR-2026-<branch code>-00001.
-
Where: company, branch (read-only; set from the creator's session default, which the SPA wizard can set via
set_session_branch), date (read-only). -
What kind:
order_type, one of the three Purchase Types. -
Who:
employee(the claimant, a Supplier) and their address. Optionallyactual_supplier_involvedplusactual_supplierand its address. When that box is off, the server copies the employee into the actual supplier. -
Allocation:
admin_log(Administrative) orproject(Operational). Optionaltransport_approvalandfreight_approval. -
Approval:
approver(a link to E-Voucher Approver).approver_limitis read-only and filled automatically from the approver'sthreshold_value. -
Money:
total_amount, read-only and always recalculated on the server from the rows. -
Rows:
expense_details, a table of E-Voucher Expense Detail. Each row has expense date, item (with read-only name and UOM), amount, description, attachment, admin log / project, from/to date, from/to location, transport/freight approval, and address. -
Other:
purchase_invoice_created,permission_level(always forced to "0"), and the naming series fields.
E-Voucher Approver is a small setup record, and only a System Manager can edit it:
-
approver_user: the Frappe user. approver_namebranch-
threshold_value: the approval limit in ₹.
Who can do what on the DocType:
- System Manager: everything.
-
10A-E-Voucher Send: create, read and write, only on vouchers they own (if_owner). -
10B-E-Voucher Approve/Reject: read, write and submit. This is narrowed further by the hooks in §3.4.
3.3 The approval workflow ("E-Voucher" Workflow)
| From | Action | To | Allowed role |
|---|---|---|---|
| Draft | Send for Approval | Sent for Approval | 10A-E-Voucher Send |
| Sent for Approval | Approve | Approved (submitted) | 10B-E-Voucher Approve/Reject |
| Sent for Approval | Reject | Rejected | 10B-E-Voucher Approve/Reject |
-
Paid is a fifth state. The workflow never moves a voucher there. The payment ledger code (
doctype/payment_ledger_entry/) sets it once the voucher's invoice is paid. - Rejected is final. The document refuses any further change, and the user must create a new voucher.
- Draft vouchers don't come from the SPA. The SPA always creates and sends in one go, so Drafts only come from the Desk.
-
Approved vouchers are then paid through the Desk:
-
make_purchase_invoiceworks only for an Approved voucher, and only once. -
make_payment_entryworks only for an Approved voucher and needs write permission.
-
3.4 Whitelisted APIs, grouped by feature
Each API is listed with what it does, what it checks, and which screen calls it. Anything marked 🔒 KEEPS IT SAFE should not be weakened.
Voucher access for every screen (B1: user scoping)
These are hooks, not APIs, but everything else depends on them. They live in e_voucher.py.
-
get_permission_query_conditions🔒 KEEPS IT SAFE- Adds a condition to every E-Voucher list query for non-admins: created by me, or my E-Voucher Approver row is the assigned approver.
- This covers
frappe.client.get_list, reports and Desk lists.
-
has_permission🔒 KEEPS IT SAFE- Applies the same rule when opening a single voucher (for example
frappe.client.get). - Administrator and System Manager are unrestricted.
- These hooks can only take access away, never add it.
- Applies the same rule when opening a single voucher (for example
Dashboard (e_voucher_dashboard_api.py)
-
get_dashboard_context→ shell, Home- Returns your user and whether you can send and/or review, based on roles. System Manager counts as both.
-
get_my_vouchers(search, status, from_date, to_date)→ My Vouchers (B1 + B4)- 🔒 Always filters
owner = the logged-in user. The caller can't change this. - The status must be one of four known chips. The search is capped at 140 characters and matched with a safe substring query. Dates are checked (from ≤ to).
- 🔒 Always filters
-
get_vouchers_to_review(search)→ Vouchers to Review (B1 + B4)- 🔒 Starts from pending vouchers whose approver row belongs to you, then checks each one with
get_approval_block(below). It never works out eligibility on its own.
- 🔒 Starts from pending vouchers whose approver row belongs to you, then checks each one with
-
get_voucher_stats(kind)→ count tiles (redesign)- Returns counts only, scoped exactly like the list underneath.
- "mine" counts your own vouchers. "review" counts vouchers assigned to you, with "pending" being the length of the review list.
- An unknown
kindis rejected.
-
get_e_voucher_timeline(voucher)→ the detail screen's Activity tab (B6)- 🔒 Requires normal read permission on the voucher.
- Built only from what Frappe already records: the voucher's creation, the "Workflow" comments Frappe adds on every transition, and ordinary comments.
- The person shown comes from the comment's server-set
owner, never from the display name a client could fake. - Comment text is converted to plain text.
Profile (e_voucher_profile_api.py, B2)
-
get_my_profile→ Profile, Profile details, top bar- 🔒 Acts only on the logged-in user. It takes no user argument, so nobody can read someone else's profile.
- Returns your name, contacts, branch, company, roles, claimant record and approval limits.
-
update_my_profile(first/middle/last name, mobile, phone)(POST only) → Profile details- 🔒 Updates only the logged-in user, and only those five fields (a fixed allow-list).
- First name is required, lengths are capped, and phone numbers are validated.
- It saves with
ignore_permissions, because normal users can't write User records. The allow-list is what keeps this safe. - Email, roles, enabled status, branch and approval limits can never be changed here. Passwords stay on Frappe's own page.
Notifications (e_voucher_notifications.py, B3)
-
notify_on_workflow_changeis a document hook, not an API. It runs after every E-Voucher save.- Only when the workflow state actually changed, it writes one Frappe Notification Log of type "Alert". That type is in-app only and never sends email.
- Sent for Approval → notifies the assigned approver.
- Approved or Rejected → notifies the voucher's creator.
- You're never notified about your own action.
- A notification failure is logged and never blocks the approval.
- Only when the workflow state actually changed, it writes one Frappe Notification Log of type "Alert". That type is in-app only and never sends email.
-
get_bell_notifications(GET) andmark_bell_notifications_read(POST, onenameor all) serve the SPA's bell.- Both filter on
for_user = frappe.session.userthemselves. - Don't switch back to
frappe.client.get_list. Frappe's Notification Log permission hook adds no condition for Administrator, so it would list every user's notifications, while Frappe'smark_as_readonly marks the caller's own rows. Marking read would then silently do nothing.
- Both filter on
Wizard and review (page/e_voucher_wizard/api.py)
-
get_bootstrap→ wizard step 1- Returns your default branch and company, which defaults are missing, the purchase types, and whether you may create a voucher.
- Also returns
branches: the branches you may book in (see_selectable_branches). A default outside that list counts as missing.
-
_selectable_branches(helper; the single "which branches may this user book in?" rule)- Branches marked Active for Transaction (
custom_is_available_for_transaction). - Narrowed to your User Permissions on Branch when you have any (for all doctypes or for E-Voucher).
- Branches marked Active for Transaction (
-
set_session_branch(branch)(POST only) → the step 1 branch picker- Sets your own branch default (the same value Desk's Session Defaults dialog sets) and returns a fresh bootstrap.
- Refuses (403) any branch outside
_selectable_branches. - Frappe clears this default at every logout (
clear_session_defaults), so you pick again after logging in. With only one selectable branch, the SPA sets it silently.
-
get_address_preview(address)→ step 3- Formatted address text. Requires Address read permission.
-
get_allocation_preview(doctype, name)→ step 4- Only Project or Administrative Log, and only a fixed list of fields. Requires read permission on that doctype.
-
get_item_evoucher_config(item_codes)→ step 5- Item name, UOM, description, and flags: is the item usable for e-vouchers, does it need dates or locations, and its type.
-
address_query(ine_voucher.py) → step 3- The address search, reached through Frappe's standard
search_link.
- The address search, reached through Frappe's standard
-
get_eligible_approvers(branch, total_amount)→ step 6 dropdown- Ignores the branch the client sends and uses your own session branch.
- Returns that branch's approvers, excluding yourself, each flagged
eligiblewhen their limit is above 0 and at least the total. - Each approver also carries an
enabledflag: whether their user account is active, the same check saving applies. - It still returns ineligible and disabled approvers, because the older Desk wizard lists everyone (it ignores
enabled). The SPA hides both.
-
create_e_voucher(payload, action, request_id)(POST only) → Send for Approval. This is the only code that creates a voucher. 🔒 KEEPS IT SAFE:- Branch and company come from your session defaults, never from the client.
- The session branch must be one of
_selectable_branches(re-checked here, since Desk's Session Defaults dialog accepts any value). - If the payload's
branchdiffers from the session branch (changed in another tab), the save is refused with "Branch Mismatch". -
Pre-checks:
- The employee, supplier, addresses, admin log and approvals must be "active for transaction".
- The order type must be valid.
- Every item must be marked usable for e-vouchers.
-
Row fields are copied from a fixed allow-list (
ALLOWED_ROW_FIELDS). The server sets total, approver limit, item name/UOM, date and permission level itself. -
Saving uses a normal
doc.insert(). It never usesignore_permissions, so all DocType permissions and document validation apply (§3.5). - Receipts are linked to the new voucher only if the file is still unattached and owned by you. Anything else is skipped and reported back.
- All or nothing: insert, receipt linking and "Send for Approval" happen together. Any failure undoes all of it.
- Replay protection: a lock plus a 10-minute cache keyed on (user, request_id). A double click or retry returns the same voucher instead of making a second one, and one user's id can never affect another user.
-
can_review_e_voucher(voucher)→ detail screen- A quiet check of whether you may decide on this voucher right now, and if not, why. It uses
get_approval_block.
- A quiet check of whether you may decide on this voucher right now, and if not, why. It uses
-
get_e_voucher_for_review(voucher)→ detail screen- 🔒 Only for pending vouchers, and only after
check_approval_eligibilitypasses. - Returns the voucher, its approver info and its files.
- 🔒 Only for pending vouchers, and only after
-
submit_review_decision(voucher, decision, modified, request_id)(POST only) → Approve / Reject. 🔒 KEEPS IT SAFE:- The decision must be exactly "Approve" or "Reject".
- It locks the voucher row and requires the
modifiedtimestamp to match what you saw. If anything changed, you're told to reload. - The voucher must still be "Sent for Approval".
- It re-checks eligibility, including the limit, then moves the voucher through Frappe's standard
apply_workflow. - The same
request_idreturns the cached result, so a double click decides only once.
Other existing E-Voucher APIs (Desk-side, unchanged by the SPA work)
-
make_purchase_invoiceandmake_payment_entry: Approved vouchers only, as in §3.3.
3.5 The rules inside the E-Voucher document itself (e_voucher.py)
These run on every save, whether it comes from the SPA, the Desk form, the Desk wizard or a script.
Before validation, the server sets three things itself:
- The total amount, summed from the rows.
- The actual supplier, copied from the employee when that box is off.
- The item settings each row's checks need.
Validation 🔒 KEEPS IT SAFE:
- Rejected is final: a Rejected voucher can't be changed.
- Order type and allocation are required: there must be a valid order type, plus an admin log (Administrative) or a project (Operational), on the voucher and on every row.
-
Each row is checked:
- The amount must be above 0.
- The item must be usable for e-vouchers and must match the order type.
- Dates or locations must be filled in when the item needs them.
- Branch consistency: the approver, admin log and project must belong to the voucher's branch.
-
Approver eligibility, while the voucher is Draft or Sent for Approval (
validate_approver_eligibility):- The approver must exist and be an enabled user.
- They must not be the person saving the voucher or its creator.
- They must be in the same branch.
- Their limit must cover the total, recalculated from the rows.
- After that stage, the total must still be within the saved approver limit.
-
Approve/Reject from any route (Desk form, list-view bulk action or API) runs
check_approval_eligibilityagain. The limit is part of that check for Approve.
get_approval_block(doc) is the one rule for "may this user approve or reject?" 🔒 KEEPS IT SAFE. The answer is no if any of these is true:
- No approver is assigned, or the approver record is missing.
- You are not the assigned approver's user.
- You created the voucher.
- You are the claimant (your email is on the Employee Supplier record).
- Your limit is below the total, recalculated from the rows.
It is used by the review list, the review screen, submit_review_decision, and the document's own transition check. If you ever change who may approve, change it here and nowhere else.
3.6 Serving the app
-
www/frontend.py+www/frontend.html:/frontendand every path under it render one HTML page.-
frontend.pyadds Frappe's boot data plus the session's CSRF token, and marks the page as never cached, because it's per-session. - Without the token, every POST from the app would fail.
-
-
frontend_sw.py: serves only the one built service-worker file at/frontend/sw.js, withno-cacheso updates are noticed.
4. "Do not weaken" checklist
If you change any of these, re-test the security behavior.
-
E-Voucher permission hooks.
get_permission_query_conditionsandhas_permissionine_voucher.pylimit non-admins to vouchers they own or are assigned to approve. get_my_vouchersalways filters byowner = session user.-
get_approval_blockis the single approval rule. It covers the assigned approver, not your own voucher, not the claimant, and the limit against the recalculated total. The review list, review screen, decision API and document validation all call it. -
validate_approver_eligibilityre-checks the approver on every save while the voucher is Draft or Sent for Approval. This is why the SPA's approver dropdown filtering is only cosmetic. -
create_e_voucher:- Branch and company come from the session.
- Row fields come from an allow-list, and server-owned fields are never taken from the client.
- It uses a normal
insert(). - Receipt ownership is checked.
- Insert and send are all-or-nothing.
- There's replay protection.
-
submit_review_decision: row lock, themodifiedmatch, the still-pending check, the eligibility re-check, and replay protection. -
The profile APIs act on the session user only, and
update_my_profilehas a five-field allow-list. - The timeline requires read permission and takes the user from the server-set comment owner.
-
Reads are GET and writes are POST with the CSRF token that
www/frontend.pyinjects. -
The service worker never caches
/api/or the page itself. There is no offline data by design. -
Rejected is final (
validate_not_rejected).