Skip to main content
Technical ManualLooking for step-by-step instructions? In the User Manual: Daily SalesService Tickets and Invoices

Sales

The Sales module keeps the sales of the business and posts them to the ledger. Its first slice is the daily sales by sales category (the legacy retail sales screen, BOOK-01). Credit sales, customer payments, deposits and other income follow in the same module.

ProjectsCrestApps.Almohaseb.Sales (module), CrestApps.Almohaseb.Sales.Core (document, calculator, store, posting rule)
FeatureCrestApps.Almohaseb.Sales (Sales & receivables), enabled by both business recipes; CrestApps.Almohaseb.Sales.Services (Service tickets & invoices), enabled by the service recipe
CollectionSales

Features and dependencies​

CrestApps.Almohaseb.Sales depends on CrestApps.Almohaseb.Ledger, CrestApps.Almohaseb.Contacts and CrestApps.Almohaseb.Items. Sales.Core references only Ledger.Core: it reads the categories through ISalesCategoryLookup, which the module implements over the Items content items (ContentSalesCategoryLookup), so the core and its tests need no content items.

DailySales​

DailySales is a CatalogItem in the Sales collection, one per business date, with the id sales-{yyyyMMdd}:

PropertyNotes
DateThe business date.
LinesDailySalesLine (CategoryId, Amount): one per category, as entered (positive).
SourceManual or PointOfSale.
RevisionIncreased by every save, so every save posts again.
TotalThe total collected, for lists.
ModifiedUtc, ModifiedBy

DailySalesIndex holds ItemId, Date, Total (decimal 19,2) and Source, with database indexes on Date and ItemId (SalesMigrations.CreateDailySalesIndexAsync, version 1).

Calculation​

DailySalesCalculator.Calculate(lines, categories, source) is pure:

  • Lines with no amount or an unknown category are skipped.
  • An always-minus category's amount is made negative.
  • Revenue = round(amount × RevenuePercent / 100, 2, away from zero) — the legacy Sale.CalculateRevenue. The rest is collected for others.
  • Computed tax = Σ round(amount × TaxRate / 100, 2) over taxable categories other than Sales Tax.
  • Tax of the day is the amount of the Sales Tax category when it is not zero or the day came from a point of sale, otherwise the computed tax — the legacy POS-tax override.
  • Total is Σ amounts; Revenue excludes the Sales Tax category.

The legacy defects are fixed: zero placeholder rows are never saved, and a minus category with a revenue share no longer fails validation.

Posting​

DailySalesPostingRule (source type Sales.DailySales, source id the document id, revision the Revision) builds one entry per day:

  • each category's revenue to its account (SalesCategoryInfo.AccountId, or the system Sales account): a credit, or a debit for a negative (subtracted) amount;
  • the collected-for-others total to the system account CollectedForOthers (Amounts Collected for Others, liability 2400, added to the ledger by migration version 6);
  • the total collected to UndepositedFunds: a debit, or a credit when the day is negative.

Only the entered sales tax is posted (through the Sales Tax category, whose account is Sales Tax Payable); the computed tax is shown, not posted. A day with no amount builds no draft, so saving it empty unposts the previous entry.

Receivables and other income​

Three more documents live in the Sales collection, each posted by its rule and re-posted at every save (IReceivableDocument.Revision):

DocumentFieldsPosting rule (source type)Entry
CreditSaleCustomerId, Date, Amount, DueDate, InDailySales (legacy "Affects Total"), CommentCreditSalePostingRule (Sales.CreditSale)Dr Accounts Receivable; Cr Undeposited Funds when InDailySales (the day's sales already credited revenue), otherwise Cr Sales
CustomerPaymentCustomerId, Date, Amount, PaymentMethodId, CommentCustomerPaymentPostingRule (Sales.CustomerPayment)Dr the method's account (PaymentMethodAccounts.Resolve, received); Cr Accounts Receivable
OtherIncomeIncomeAccountId (the legacy income type is a ledger account), Date, RelatedMonth (first of the month), Amount (non-zero, may be negative), PaymentMethodId, CustomerId, CommentOtherIncomePostingRule (Sales.OtherIncome)Dr method account, Cr income account; reversed sides for a negative amount

ReceivableIndex (ItemId, CustomerId, Date, Amount signed +sale/−payment, Kind) is filled by ReceivableIndexProvider and CustomerPaymentIndexProvider; OtherIncomeIndex (ItemId, Date, IncomeAccountId, CustomerId, Amount) by OtherIncomeIndexProvider. Migration version 2 creates them (SalesMigrations.CreateReceivableIndexesAsync), with database indexes on CustomerId, Date, Date and ItemId.

IReceivablesService / ReceivablesService saves, lists, deletes and balances them:

  • Validation (ReceivableValidationCode): an existing customer (through ICustomerLookup, implemented by ContentCustomerLookup over Contacts), a date, a positive amount (non-zero for other income) with at most two decimals, an existing payment method that is not OnAccount, an income account; a payment cannot exceed what the customer owes without it (MoreThanOwed), so an edit can keep its amount.
  • The document is saved only when its posting succeeds. DeleteAsync posts an empty copy (unposting the entry) and deletes the document only on success, so nothing in a closed period can be deleted.
  • GetBalanceAsync, ListBalancesAsync run one GROUP BY CustomerId over ReceivableIndex in the session's transaction (the legacy GetCustomerTotalCredit stored procedure, whose "since last zero balance" join filtered nothing, is replaced by a plain lifetime balance). GetStatementAsync returns the rows in date order with the running balance.

Service tickets and invoices​

The Service tickets & invoices feature (CrestApps.Almohaseb.Sales.Services, ServicesStartup, depends on Sales) replaces the legacy service sales, invoices and their PDF (BOOK-02 to BOOK-04). The legacy grouped ticket rows by a GUID, recreated them with a new GUID on every edit and so orphaned the invoice row, saved lines, invoice and counter separately, and swallowed save errors.

ServiceTicket (in the Sales collection) is one document: Number and NumberSequence, Date, CustomerId, PaymentMethodId, OnAccount (set from the payment method's role at save), DueDate, Description, Lines and Total. A ServiceTicketLine has CategoryId, Description, Quantity, UnitPrice, Shipping and Tax (null until worked out); Amount = round2(quantity × price) and Total = amount + shipping + tax, the legacy line total.

  • ServiceTicketCalculator.Normalize drops empty lines, trims, and sets a missing tax to round2(amount × category rate) when the category is taxable; Validate returns ServiceTicketErrors (code and line number): DateRequired, PaymentMethodRequired, CustomerRequired (on account), DueDateBeforeDate, LinesRequired, CategoryRequired, QuantityRequired, TooManyDecimals (two for money, four for quantities), TotalRequired.
  • IServiceTicketService / ServiceTicketService: SaveAsync normalizes, defaults an invoice's due date to the date plus the customer's PaymentTermsDays (ICustomerLookup.GetDetailsAsync), validates, refuses with PaymentsExceedInvoices a change that would leave the customer having paid more than invoiced, numbers the ticket from Sales.Invoice (INV-, from 1001) or Sales.Receipt (R-, from 1001) through IDocumentNumberService (a ticket that changes kind takes a number of the new kind; an edit keeps its number), posts, and saves only when posting succeeds, all in one unit of work. DeleteAsync applies the same balance guard, posts an empty copy and deletes on success. ListAsync(from, to, customerId, onAccount) reads ServiceTicketIndex.
  • ServiceTicketPostingRule (Sales.ServiceTicket): Dr Accounts Receivable on account, otherwise the payment method's account; Cr each category's income account (Sales when it has none) for amount + shipping, grouped by account; Cr Sales Tax Payable for the tax. Negative totals swap the sides.
  • ServiceTicketIndexProvider maps every ticket to ServiceTicketIndex (ItemId, Number, Date, CustomerId, OnAccount, DueDate, Total; ServiceTicketMigrations creates it in the Sales collection) and, only when on account, to ReceivableIndex with ReceivableKind.Invoice. The balance aggregate counts every kind but Payment as sold, so invoices add to customer balances, statements and the payment limit with no change to the receivables screens beyond their labels.
  • ServiceTicketDocumentBuilder gathers the document (business name from the profile's DBA, legal name or the site name; address and phone; the customer's details, address and e-mail from the AlmohasebCustomer content item; category and payment method names; the customer balance) and RenderHtml renders it as self-contained HTML with inline styles. The print page (Document, a page without the admin layout, with a print stylesheet) and the e-mail body use the same HTML. PDF output waits for the CrestApps PDF module (U14).
  • Send e-mails the document through IEmailService when Email is enabled (the form is hidden otherwise), to a validated address and an optional copy.
Route (under the admin prefix)Route namePermission
Almohaseb/Sales/ServiceTickets (from, to, customerId, kind = invoices or receipts)AlmohasebServiceTicketsView receivables
Almohaseb/Sales/ServiceTickets/Create (customerId, invoice), /Edit/{id}, /Save, /Delete/{id}AlmohasebServiceTicketCreate, ...Enter receivables
Almohaseb/Sales/ServiceTickets/Document/{id}AlmohasebServiceTicketDocumentView receivables
Almohaseb/Sales/ServiceTickets/Send/{id} (POST email, cc)AlmohasebServiceTicketSendEnter receivables

The menu adds Sales > Service tickets (almohasebServiceTickets) and Sales > Invoices (almohasebInvoices, the list with kind=invoices).

Services​

ServiceLifetimeWhat it does
IDailySalesService / DailySalesServiceScopedGetAsync(date), ListAsync(from, to) and SaveAsync(date, lines, source, user): merges lines by category, validates (DailySalesValidationCode: UnknownCategory for an unknown or purchase category, NegativeAmount, TooManyDecimals), posts through IPostingService, and saves the document only when the posting succeeds.
ISalesCategoryLookup / ContentSalesCategoryLookupScopedGetAsync(ids) and ListForSalesAsync() as SalesCategoryInfo records.
IReceivablesService / ReceivablesServiceScopedCredit sales, customer payments, balances, statements and other income. See Receivables and other income.
ICustomerLookup / ContentCustomerLookupScopedGetNamesAsync(ids) and ListAsync() over the active customers; GetDetailsAsync(id) (a default interface method returning null) gives documents the name, company, address lines, phone, e-mail and payment terms.
IServiceTicketService / ServiceTicketServiceScopedService tickets and invoices. See Service tickets and invoices.

Permissions​

PermissionKeyDefault roles
Enter and change the daily salesManageAlmohasebSalesAdministrator
View the daily salesViewAlmohasebSalesAdministrator

| Enter credit sales, customer payments and other income | ManageAlmohasebReceivables | Administrator | | View credit sales, customer balances and other income | ViewAlmohasebReceivables | Administrator |

Enter and change the daily sales implies View the daily sales, and Enter credit sales, customer payments and other income implies View credit sales, customer balances and other income.

Admin menu and routes​

The module adds a Sales group (id almohasebSales) with Daily sales (id almohasebDailySales, shown with View the daily sales), Credit sales and payments (almohasebReceivables), Customer balances (almohasebCustomerBalances) and Other income (almohasebOtherIncome), shown with View credit sales, customer balances and other income.

Route (under the admin prefix)Route nameMethodsPermission
Almohaseb/Sales/DailyAlmohasebDailySalesGET (date, default today's business date), POST (save and post)View; Enter and change to save
Almohaseb/Sales/ReceivablesAlmohasebReceivablesGET (from, to, customerId; default the current month)View receivables
Almohaseb/Sales/CustomersAlmohasebCustomerBalancesGETView receivables
Almohaseb/Sales/Customers/Statement/{customerId}AlmohasebCustomerStatementGETView receivables
Almohaseb/Sales/CreditSale/Create, Almohaseb/Sales/Payment/CreateAlmohasebCreditSaleCreate, AlmohasebPaymentCreateGET (customerId)Enter receivables
Almohaseb/Sales/Receivable/Edit/{id}, /Save, /Delete/{id}AlmohasebReceivableEdit, AlmohasebReceivableSave, AlmohasebReceivableDeleteGET, POST, POSTEnter receivables
Almohaseb/Sales/OtherIncome (+ /Create, /Edit/{id}, /Save, /Delete/{id})AlmohasebOtherIncome, ...GET (from, to), editor, POSTView; Enter receivables to change

The screen lists the active sales categories in SalesCategoryRules.Order, plus any category the day already uses, and uses the Admin UI editable grid in its fixed-rows mode for the running total.

Recipes and deployment​

Both business recipes enable CrestApps.Almohaseb.Sales; the service recipe also enables CrestApps.Almohaseb.Sales.Services. The module adds no recipe or deployment step.

Extension points​

  • Point-of-sale imports: call IDailySalesService.SaveAsync with DailySalesSource.PointOfSale; the register's sales tax then wins over the computed tax.

See also​