- Added fields for invoice and estimate number prefixes, date parts, padding, and starting numbers in the organization settings. - Updated the organization edit and display screens to show examples of new invoice and estimate numbers based on the selected settings. - Introduced validation for number prefixes and padding, ensuring compliance with server requirements. - Implemented tests for the new numbering functionality, covering various scenarios including prefix validation and starting number rules. - Enhanced the mobile app to reflect these changes in the organization settings and display screens.
29 KiB
Invoices and estimates
Authenticated routes in backend/invoices/api_urls.py, all mounted under /api/invoices/.... The
DRF views live in backend/invoices/api_views.py, not views.py, which is a legacy,
non-API module in this app. Anonymous client-portal routes are in
backend/invoices/public_urls.py, served by backend/invoices/public_views.py, mounted at
/api/public/.... Authorization for every authenticated invoice/estimate/recurring-invoice endpoint
is centralized in backend/invoices/permissions.py. See Conventions for pagination,
filtering and response-shape rules assumed rather than repeated here, and Errors for the
404-vs-403 principle this page's own module docstring states almost verbatim.
This page covers invoices, estimates, line items, account statements and the public portal, the
endpoints this documentation task scopes. Payments, products, templates, recurring invoices, dashboards/reports, and
invoice-from-opportunity/from-time-entries are also part of this module (api_urls.py) but are out of
scope for this page.
One access rule for invoices, estimates and recurring invoices
invoices/permissions.py states a single rule, reused by all three record types via
get_invoice_or_error/get_estimate_or_error/get_recurring_or_error
(invoices/permissions.py:101-122):
1. Does this record exist in the caller's org? -> 404 if not
2. May this caller act on it? -> 403 if not
The exact two-step Errors: Not found versus forbidden quotes
as this module's own docstring. Step 2, has_object_access (:32-58), grants access to an org admin,
a Django superuser, the record's creator, or any profile in its assigned_to set. There is no
separate, narrower rule for write vs. delete the way Cases
has; one rule covers read, write and delete alike for invoices and estimates.
The two comparisons inside that rule are against deliberately different types. created_by is a
User FK (from AuditModel), so it is compared against request.user; assigned_to holds Profile
rows, so it is compared against request.profile (:42-58). The module docstring calls out that
getting this backwards (comparing request.profile to created_by, as the estimate views used to)
is not merely always-False the way the same mistake is elsewhere in this codebase: because it reaches
the ORM as Q(created_by=<Profile instance>), Django raises ValueError on the type mismatch, and the
non-admin estimate list answered 500 rather than an empty or wrong list. That specific defect is
fixed in the current source (_get_or_error/has_object_access as shown), but it is a concrete example
of why this codebase keeps insisting a permission check be provably tested in both directions.
Invoices
GET /api/invoices/ (InvoiceListView.get, invoices/api_views.py:176-194) hand-builds a response
with the same count/next/previous/results key names DRF's get_paginated_response() would
produce, plus a totals block computed over every invoice the caller can see (not just the page or the
current filter). This is the hand-rolled shape, not the DRF-generated one,
Conventions and Cases both name
GET /api/cases/solutions/ as the only endpoint in this API that actually calls
get_paginated_response(); this view reaches the same four key names by building the dict itself:
{
"count": 41,
"next": null,
"previous": null,
"results": [ { "...": "InvoiceListSerializer" } ],
"totals": {
"count": 41, "outstanding": "12500.00", "overdue": "3200.00",
"due_this_month": "4100.00", "paid_this_quarter": "9800.00",
"draft": "600.00", "action_needed": 5,
"by_currency": [
{ "currency": "USD", "count": 41, "action_needed": 5, "outstanding": "12500.00", "...": "..." }
]
}
}
totals is computed over Invoice.objects.filter(pk__in=queryset.values("pk")) rather than the
visibility-scoped queryset directly (_totals, :196-235), the comment explains why: a non-admin's
queryset joins assigned_to (a many-to-many), so summing across that join would multiply an invoice's
amount by its number of assignees; re-querying by pk__in drops the join before the Sum.
Money is grouped by currency, as in the /api/invoices/reports/ endpoints: each amount is the plain figure when the
visible invoices use at most one currency and null when they use several, and by_currency carries
one row per currency either way. count and action_needed are totalled across currencies.
action_needed is a count, not an amount, invoices still Draft, plus anything unpaid and past
due_date (:209). A non-admin caller sees only invoices they created or are assigned to (:96-103,
the same rule above applies at list
scope).
Filters (filter_queryset, :105-174): search (matches invoice_title, invoice_number,
client_name or client_email, contains, capped to 100 characters), status (exact),
account/contact/opportunity (exact id), assigned_to (exact id), created_by (exact id),
issue_date_gte/issue_date_lte, due_date_gte/due_date_lte, cf_<key>, and sort: whitelisted
to created_at/due_date/issue_date/total_amount/status, each optionally --prefixed
(:163-172); an unrecognized sort value is silently ignored, leaving the default -created_at.
POST /api/invoices/ (:237-292) is validated by InvoiceCreateSerializer
(invoices/serializer.py:542-618; see Fields). On success (201):
{"error": false, "message": "Invoice created successfully", "invoice": { "...": "InvoiceSerializer" }}
Creating an invoice queues create_invoice_history (an audit-trail Celery task) and, if the invoice
already has assignees, send_email to notify them (:261-278).
GET /api/invoices/{id}/ (InvoiceDetailView.get, :295-341) returns
{"invoice": ..., "attachments": [...], "comments": [...], "history": [...], "custom_field_definitions": [...]}.
PUT /api/invoices/{id}/ (:343-400) accepts a partial body (partial=True is passed even though
this is PUT, :376) through the same InvoiceCreateSerializer. InvoiceDetailView defines only
get and put, no delete method of any kind exists for an invoice; an invoice is retired via
POST /api/invoices/{id}/cancel/, never removed. (Contrast Estimates, which does support
a hard delete.)
Three lifecycle actions, each get_invoice_or_error-gated and each a POST with no request body:
POST /api/invoices/{id}/send/(InvoiceSendView.post,:403-440) refuses to re-send aPaidorCancelledinvoice (400); movesDraft→Sent, stampssent_at/is_email_sent, and queuessend_invoice_to_client.POST /api/invoices/{id}/mark-paid/(InvoiceMarkPaidView.post,:443-481) records aPayment. It defaultsamountto the currentamount_due,payment_methodtoOTHER, andpayment_dateto today when the caller omits them, but routes the whole thing throughPaymentCreateSerializer(invoices/serializer.py:365-396) either way, so a payment amount of0or one exceeding the outstanding balance is still rejected (400).POST /api/invoices/{id}/cancel/(InvoiceCancelView.post,:563-599) refuses an already-Cancelledinvoice and refuses aPaidone (400either way). Cancelling is for money that will never be collected, not for reversing a completed payment.
POST /api/invoices/{id}/duplicate/ (InvoiceDuplicateView.post, :484-560) copies the invoice's
client/billing/discount/tax/template fields and every line item into a new Draft invoice with a fresh
invoice_number, issue_date reset to today, and no payments; 201 with the new
InvoiceSerializer payload.
GET /api/invoices/{id}/pdf/ (InvoicePDFView.get, :602-631) streams
application/pdf; a missing PDF backend answers 503, any other generation failure 500.
Estimates
The same list/detail/lifecycle shape as invoices, with one structural difference: an estimate can be hard-deleted; an invoice cannot (see above).
GET /api/invoices/estimates/ (EstimateListView.get, :1027-1064). Same hand-built
count/next/previous/results shape as the invoice list above, without a totals block. Filters:
status, account, search (matches
estimate_number, title or client_name), cf_<key>. Visibility follows the same rule as invoices,
non-admin sees only estimates they created or are assigned to.
POST /api/invoices/estimates/ (:1066-1100) is validated by EstimateCreateSerializer
(invoices/serializer.py:892-1018; see Fields). Unlike InvoiceCreateSerializer,
EstimateCreateSerializer does not mark status read-only, nothing stops an authenticated,
same-org caller with write access from POSTing or PUTing an estimate directly into
status: "Accepted". Doing so does not run any of the checks or side effects the public accept
flow enforces (see Public portal endpoints): no expiry check, and none of
accepted_by_name/accepted_by_email/accepted_ip/accepted_user_agent get populated, even though
the model comment above those fields calls acceptance "an authorisation" that the app records who gave.
This is reported here, not fixed, per this documentation task's scope.
GET/PUT/DELETE /api/invoices/estimates/{id}/ (EstimateDetailView, :1103-1187); DELETE is a
real hard delete (estimate.delete(), :1182), gated by the same access rule as read/write.
POST /api/invoices/estimates/{id}/convert/ (EstimateConvertView.post, :1189-1266) creates a new
Draft invoice from the estimate's client/billing/discount/tax fields and line items, then sets
estimate.status = "Accepted", accepted_at = now(), and estimate.converted_to_invoice = invoice
(:1253-1257). This is the field that stops a second conversion from duplicating the invoice: the
view's very first check is if estimate.converted_to_invoice: return 400 (:1200-1204), before
anything else runs. Any client rendering an estimate list needs to read this field, not just status,
to decide whether to offer "raise an invoice",
EstimateListSerializer.converted_to_invoice (invoices/serializer.py:832,846,866-870) is on the list
row specifically so the worklist can tell "accepted, not yet billed" apart from "already billed" without
a second request per row; the class docstring is explicit that omitting it "would offer to raise a
second invoice for an estimate that already has one."
POST /api/invoices/estimates/{id}/send/ (EstimateSendView.post, :1269-1291) moves Draft → Sent
and queues send_estimate_to_client. GET /api/invoices/estimates/{id}/pdf/
(EstimatePDFView.get, :1294-1334) streams the PDF the same way the invoice one does.
Line items
Two ways line items reach the database, with different validation coverage.
Nested in an invoice create/update. InvoiceCreateSerializer's line_items
(InvoiceLineItemCreateSerializer, many, invoices/serializer.py:288-304) is written inside the same
request as the invoice itself; validate() additionally rejects any line item whose product belongs
to a different org (:689-704: the comment notes the nested serializer's own product field is an
unscoped PrimaryKeyRelatedField over every org's products, so this cross-org check has to happen at
the parent serializer, where org context exists). On PUT, sending line_items replaces the set.
Existing rows are deleted and recreated (InvoiceCreateSerializer.update, :731-753); omitting the key
entirely leaves existing line items untouched.
Standalone, via POST /api/invoices/{invoice_id}/line-items/
(InvoiceLineItemListView.post, invoices/api_views.py:653-679) and
PUT/DELETE /api/invoices/{invoice_id}/line-items/{id}/
(InvoiceLineItemDetailView, :682-751). Both route through InvoiceLineItemCreateSerializer
directly, with no wrapping parent-serializer validate(), and neither has an application-code check
that product belongs to the caller's org: InvoiceLineItemCreateSerializer declares no product
field of its own (invoices/serializer.py:288-304), so DRF derives a plain
PrimaryKeyRelatedField(queryset=Product.objects.all()), every org's products, and both views use it
raw, unlike the nested path, which does check (InvoiceCreateSerializer.validate(), :689-704; grep
confirms this is the only product-org check anywhere in invoices/serializer.py). This is a real gap
against the ORM-filter contract,
the explicit application-level filter that page calls non-optional is missing here, but it is not an
unconditional cross-tenant read. product (the table, along with
invoice_line_item) is itself RLS-protected: both are in ORG_SCOPED_TABLES
(common/rls/__init__.py:97), with policy enabled by
common/migrations/0008_enable_rls_product_invoice_line_item.py. Under the non-superuser database role
PostgreSQL and RLS mandates, the unscoped
Product.objects.all() queryset sees zero rows for a foreign
org's id under RLS's fail-safe default, and the request answers 400 ("invalid pk") rather than
attaching the row. The missing ORM filter is caught by the safety net. Under the default
configuration, DBUSER unset, which falls back to the Postgres superuser, and therefore bypasses RLS
entirely, per Multi-tenancy and RLS,
nothing stops it: the row attaches, and InvoiceLineItemSerializer.product_name
(source="product.name", :254,263) discloses that other org's product name on every subsequent read.
Whether this is exploitable in a given deployment is exactly the question "is DBUSER a superuser?"
answers, which is also why it's worth fixing regardless of how any one deployment is configured: the
ORM filter is the contract, RLS is the net, and this endpoint is missing the contract. Reported here,
not fixed, per this documentation task's scope.
Every write to a line item (either path) recalculates and saves the parent invoice's totals
immediately afterward (invoice.recalculate_totals(); invoice.save()). A client does not need a
separate call to refresh total_amount/amount_due after adding, editing or removing a line item.
InvoiceLineItemCreateSerializer.Meta.fields (invoices/serializer.py:291-303):
product, name, description, quantity, unit_price, discount_type, discount_value,
tax_rate, order. Every one of these is optional; product is nullable, name is blank=True, null=True, description is blank=True, quantity defaults 1, unit_price/discount_value/
tax_rate/order all default 0, and discount_type is blank=True over DISCOUNT_TYPES
(PERCENTAGE, FIXED) (invoices/models.py:512-565,71-74). Posting {} is valid and creates a
line item with quantity=1, unit_price=0. Estimate line items work identically;
EstimateLineItemCreateSerializer has the same field list (invoices/serializer.py:306-321), except
there is no standalone endpoint for them at all: estimate line items are only ever written nested
inside POST/PUT /api/invoices/estimates/{id}/, and EstimateCreateSerializer.validate()
(:969-980) has no application-level product-org check whatsoever, not even the
parent-serializer-level check InvoiceCreateSerializer has. The same RLS caveat applies here as above:
product is RLS-protected, so this is a missing-ORM-filter gap caught by the safety net under a
correctly-configured (non-superuser) database role, and a real cross-tenant read under the default,
superuser one.
Account statements
| Method | Path | Notes |
|---|---|---|
| GET | /api/invoices/statements/{account_id}/ |
The statement as JSON |
| GET | /api/invoices/statements/{account_id}/pdf/ |
The same statement as a PDF, Statement_<start>_<end>.pdf |
What one account was billed and paid over a period (issue #810). Computed on every request from the
invoices and payments on record (backend/invoices/statements.py), never stored, so it cannot
disagree with them.
Admin-only. A member gets 403 before the account is looked up, so the answer says nothing about
which accounts exist. The rule is the invoice reports' (_forbid_non_admin_reports): a statement sums
every invoice on the account, including ones a member may not open, and one built from only the
invoices a member can see would show the customer a wrong balance. An account in another org is 404.
Period. start_date and end_date (YYYY-MM-DD, both included) default to this month so far, in
the org's day. A period longer than 366 days, or one that ends before it starts, is a 400 with the
DRF field shape ({"end_date": ["..."]}).
Shape. One entry in currencies per currency, ordered by code, since nothing converts between
them: opening_balance, the period's invoiced, paid and cancelled totals, closing_balance,
and entries in date order. Each entry has date, kind (invoice, payment or cancellation),
invoice_id, invoice_number, description, a signed amount (positive adds to what the account
owes) and the running balance. A negative balance means the account is in credit. A currency with no
opening balance and nothing in the period is left out.
What counts. An invoice is on the statement once its customer was asked to pay: Draft and Pending are left off. A cancelled invoice stays on if it was sent or paid toward, billed on its issue date and reversed on the day it was cancelled, so an earlier month's statement still closes on the balance it showed then, and a payment it took leaves the account in credit. A cancelled invoice nobody was sent or paid was never billed, and is left off.
Document numbers
An org chooses how its new invoice and estimate numbers look through five fields on
GET/PATCH /api/org/settings/. Any member may read them; only an admin may change them (403
otherwise). A number is the prefix, then the date and a hyphen if the org dates its numbers, then a
zero-padded counter.
| Field | Default | Rule |
|---|---|---|
invoice_number_prefix |
INV- |
Up to 20 letters, digits, - and _; may be blank |
estimate_number_prefix |
EST- |
Same rule |
number_date_part |
DAY |
NONE, YEAR (2026), MONTH (202610) or DAY (20261015) |
number_padding |
4 |
Counter width, 1 to 10 digits. A counter that outgrows it is not cut |
invoice_number_start |
1 |
Lowest counter the next invoice may take, 1 to 999,999,999. Must be 1 unless number_date_part is NONE |
- The defaults are the format every org had before these fields existed, so nothing changes until an admin changes it. A number already issued never moves.
- The counter restarts whenever the part before it changes. With
YEAR, the first invoice of 2027 isINV-2027-0001. WithNONEit is one running sequence. - Estimates share the date part and padding and have their own prefix. The starting number is for invoices only, for an org carrying on a sequence from another system. If invoices already go past it, numbering carries on from the highest.
- Only numbers of exactly the current shape are counted. After switching from the default to
NONEwith prefixINV-, an earlierINV-20260915-0007is not counted, so the next invoice isINV-0001. PATCHwithinvoice_number_startother than1and a datednumber_date_partis a 400 oninvoice_number_start. That includes aPATCHthat adds a date to a sequence which already has a start: sendinvoice_number_start: 1with it.
Public portal endpoints
Mounted at /api/public/..., not under /api/invoices/...: backend/crm/urls.py:23:
path("api/public/", include("invoices.public_urls", ...)). Every view here sets
authentication_classes = [] and permission_classes = [] (invoices/public_views.py). These
endpoints are genuinely anonymous, reachable by anyone holding the URL, by design (an emailed invoice
or estimate link).
Resolving the org happens before the RLS context is set, from an unscoped token lookup. This is the
whole reason the endpoint works at all under RLS. _resolve_org_context(token, resource_type)
(invoices/public_views.py:30-46) calls common.portal_tokens.resolve_portal_org, which hashes the
raw URL token (sha256) and looks it up in common.models.PortalAccessToken, a small table carrying
no RLS policy. To get an org_id, then calls set_rls_context(org_id). Only after that does the
view run its own Invoice/Estimate query. An unknown or malformed token leaves the RLS context
empty; the subsequent scoped query then returns nothing under RLS's fail-safe default, and the caller
gets the same 404 a disabled link would give, a stranger probing token values learns nothing about
whether one is real. Architecture: Multi-tenancy and RLS
documents this mechanism in full, including why the lookup table has to be unscoped in the first place
(you cannot set app.current_org until you know the org, and you cannot look up the row that would
tell you the org until the context is set). Read that page for the general pattern; this page is the
concrete instance of it for invoices and estimates. Every endpoint below additionally requires
public_link_enabled=True on the record, independent of the token resolving at all. An admin can
revoke a previously-shared link without rotating the token.
Dates on these endpoints are the org's, not the server's. No one is signed in, so nothing else
would set a timezone and every "today" would be the server's UTC day. Once the token resolves,
_resolve_org_context also activates the org's timezone (Org.timezone) for the rest of the request.
So whether an estimate has expired (the accept check below, and the "(Expired)" marker on the estimate
PDF) is judged on the org's calendar day: at 23:30 UTC on 31 December an estimate expiring that day is
already expired for an org in Asia/Kolkata and still acceptable for an org in UTC.
GET /api/public/invoice/{token}/(PublicInvoiceView.get,:61-163). Returns the invoice's client-facing fields, billing address, line items and payment history. First view stampsviewed_atand flipsSent→Viewed.GET /api/public/invoice/{token}/pdf/(PublicInvoicePDFView.get,:166-206), same PDF the authenticated endpoint generates,include_payments=True.GET /api/public/estimate/{token}/(PublicEstimateView.get,:209-301), same shape, for estimates; also stampsviewed_aton first view.GET /api/public/estimate/{token}/pdf/(PublicEstimatePDFView.get,:304-344).POST /api/public/estimate/{token}/accept/(PublicEstimateAcceptView.post,:347-430). The authenticatedstatuswrite does not exist for the anonymous caller, so acceptance is its own endpoint with its own rules, all enforced server-side because "the frontend is not a trust boundary" (comment at:359-363): the estimate must currently beSentorViewed(400otherwise); it must not be expired (estimate.is_expired, checked againstexpiry_date,400with the expiry date in the message if it is, enforced here for the first time; the model comment notesexpiry_datewas previously "stored and printed on the estimate but enforced nowhere"); and the request body must supplynameand a syntactically validemail(400otherwise). On success, the view records who accepted:accepted_by_name,accepted_by_email(truncated to the column's max length),accepted_ip(common.request_meta.client_ip:REMOTE_ADDR, or withNUM_PROXIESset the address our outermost proxy saw; treated as evidence, not proof), andaccepted_user_agent, alongsidestatus = "Accepted"andaccepted_at.POST /api/public/estimate/{token}/decline/(PublicEstimateDeclineView.post,:433-468): sameSent/Viewedprecondition, no identity capture, setsstatus = "Declined"anddeclined_at.
Fields
InvoiceCreateSerializer.Meta.fields (invoices/serializer.py:558-618) is what POST /api/invoices/
and PUT /api/invoices/{id}/ accept.
| Field | Type | Required | Notes |
|---|---|---|---|
invoice_title |
string, max 100 | required | |
account_id |
uuid | required | Must belong to the caller's org; the model itself allows null, but the serializer makes it mandatory |
contact_id |
uuid | required | Must belong to the caller's org; must belong to account_id if both given |
opportunity_id |
uuid | optional | Must belong to the caller's org |
template_id |
uuid | optional | Must belong to the caller's org, without this check a cross-org template's branding/markup would render on this invoice's PDF |
client_name, client_email, client_phone |
string | optional | Denormalized for the PDF and portal; both client_name/client_email default "" |
billing_* / client_* address fields |
string | optional | billing_country/client_country are one of COUNTRIES |
discount_type |
one of PERCENTAGE, FIXED |
optional | |
discount_value, tax_rate, shipping_amount |
decimal | optional | Default 0 |
currency |
one of CURRENCY_CODES |
optional | Defaults USD |
issue_date |
date | optional | Defaults to today in the org's timezone (Org.timezone), not the server's UTC day |
due_date |
date | optional | |
payment_terms |
one of PAYMENT_TERMS |
optional | Defaults NET_30 |
reminder_enabled, reminder_days_before, reminder_days_after, reminder_frequency |
, | optional | |
public_link_enabled |
boolean | optional | Gates the portal endpoints independently of the token; defaults true |
notes, terms |
text | optional | |
billing_period, po_number |
string | optional | |
line_items |
list | optional | See Line items |
status |
one of INVOICE_STATUS |
read-only | Always created Draft; changed only via send/mark-paid/cancel |
invoice_number is not part of the serializer at all. Invoice.save() generates and assigns it
server-side the first time the row is saved, in the format the org chose (see
Document numbers). By default that is INV-YYYYMMDD-NNNN, with the date taken
from the org's day.
EstimateCreateSerializer.Meta.fields (invoices/serializer.py:904-928) mirrors the invoice shape
closely: title (required), account_id/contact_id (required, org-validated),
opportunity_id (optional, org-validated), the same client/address/discount/tax/currency/notes/terms
fields, issue_date (optional, defaults to today in the org's timezone), expiry_date (optional, no
default. This is what the public accept endpoint checks), public_link_enabled,
and line_items. status is writable here. See the caveat in Estimates. Like
invoices, estimate_number is server-generated on first save in the org's chosen format (by default
EST-YYYYMMDD-NNNN, the org's day), not part of the serializer.
GET responses on both (InvoiceSerializer/EstimateSerializer, invoices/serializer.py:444-539, 873-889) additionally return, but never accept as input: id, subtotal, discount_amount,
tax_amount, total_amount, amount_paid, amount_due (all computed by
recalculate_totals()), sent_at/viewed_at/paid_at/cancelled_at (invoice) or
sent_at/viewed_at/accepted_at/declined_at/accepted_by_* (estimate), public_token,
public_url, created_by, created_at, updated_at, and, on the estimate only,
converted_to_invoice (see Estimates).
Recurring schedule dates
POST /api/invoices/recurring/ and PUT /api/invoices/recurring/{id}/ (RecurringInvoiceCreateSerializer)
take start_date and next_generation_date. The daily generator bills a schedule when
next_generation_date is on or before today in the org's timezone and never reads start_date, so
the two are tied together on the server:
- On create, an omitted
start_dateis today in the org's timezone, and an omittednext_generation_dateis thestart_date(sent or defaulted). A schedule created to start next month with nonext_generation_datefirst bills next month. - A
next_generation_dateearlier thanstart_dateis refused with400and{"errors": {"next_generation_date": ["The next generation date cannot be before the start date."]}}, on create and on any update that sends either date (the other is read from the stored row). An update that sends neither date is not checked, so a schedule saved before this rule can still be renamed or paused. - A
next_generation_datelater thanstart_dateis allowed (a schedule that starts now and first bills at the end of the month).