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.
| Projects | CrestApps.Almohaseb.Sales (module), CrestApps.Almohaseb.Sales.Core (document, calculator, store, posting rule) |
| Feature | CrestApps.Almohaseb.Sales (Sales & receivables), enabled by both business recipes; CrestApps.Almohaseb.Sales.Services (Service tickets & invoices), enabled by the service recipe |
| Collection | Sales |
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}:
| Property | Notes |
|---|---|
Date | The business date. |
Lines | DailySalesLine (CategoryId, Amount): one per category, as entered (positive). |
Source | Manual or PointOfSale. |
Revision | Increased by every save, so every save posts again. |
Total | The 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 legacySale.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.
Totalis Σ amounts;Revenueexcludes 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 systemSalesaccount): 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):
| Document | Fields | Posting rule (source type) | Entry |
|---|---|---|---|
CreditSale | CustomerId, Date, Amount, DueDate, InDailySales (legacy "Affects Total"), Comment | CreditSalePostingRule (Sales.CreditSale) | Dr Accounts Receivable; Cr Undeposited Funds when InDailySales (the day's sales already credited revenue), otherwise Cr Sales |
CustomerPayment | CustomerId, Date, Amount, PaymentMethodId, Comment | CustomerPaymentPostingRule (Sales.CustomerPayment) | Dr the method's account (PaymentMethodAccounts.Resolve, received); Cr Accounts Receivable |
OtherIncome | IncomeAccountId (the legacy income type is a ledger account), Date, RelatedMonth (first of the month), Amount (non-zero, may be negative), PaymentMethodId, CustomerId, Comment | OtherIncomePostingRule (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 (throughICustomerLookup, implemented byContentCustomerLookupover Contacts), a date, a positive amount (non-zero for other income) with at most two decimals, an existing payment method that is notOnAccount, 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.
DeleteAsyncposts an empty copy (unposting the entry) and deletes the document only on success, so nothing in a closed period can be deleted. GetBalanceAsync,ListBalancesAsyncrun oneGROUP BY CustomerIdoverReceivableIndexin the session's transaction (the legacyGetCustomerTotalCreditstored procedure, whose "since last zero balance" join filtered nothing, is replaced by a plain lifetime balance).GetStatementAsyncreturns 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.Normalizedrops empty lines, trims, and sets a missing tax to round2(amount × category rate) when the category is taxable;ValidatereturnsServiceTicketErrors (code and line number):DateRequired,PaymentMethodRequired,CustomerRequired(on account),DueDateBeforeDate,LinesRequired,CategoryRequired,QuantityRequired,TooManyDecimals(two for money, four for quantities),TotalRequired.IServiceTicketService/ServiceTicketService:SaveAsyncnormalizes, defaults an invoice's due date to the date plus the customer'sPaymentTermsDays(ICustomerLookup.GetDetailsAsync), validates, refuses withPaymentsExceedInvoicesa change that would leave the customer having paid more than invoiced, numbers the ticket fromSales.Invoice(INV-, from 1001) orSales.Receipt(R-, from 1001) throughIDocumentNumberService(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.DeleteAsyncapplies the same balance guard, posts an empty copy and deletes on success.ListAsync(from, to, customerId, onAccount)readsServiceTicketIndex.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.ServiceTicketIndexProvidermaps every ticket toServiceTicketIndex(ItemId,Number,Date,CustomerId,OnAccount,DueDate,Total;ServiceTicketMigrationscreates it in theSalescollection) and, only when on account, toReceivableIndexwithReceivableKind.Invoice. The balance aggregate counts every kind butPaymentas sold, so invoices add to customer balances, statements and the payment limit with no change to the receivables screens beyond their labels.ServiceTicketDocumentBuildergathers 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 theAlmohasebCustomercontent item; category and payment method names; the customer balance) andRenderHtmlrenders 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).Sende-mails the document throughIEmailServicewhen Email is enabled (the form is hidden otherwise), to a validated address and an optional copy.
| Route (under the admin prefix) | Route name | Permission |
|---|---|---|
Almohaseb/Sales/ServiceTickets (from, to, customerId, kind = invoices or receipts) | AlmohasebServiceTickets | View receivables |
Almohaseb/Sales/ServiceTickets/Create (customerId, invoice), /Edit/{id}, /Save, /Delete/{id} | AlmohasebServiceTicketCreate, ... | Enter receivables |
Almohaseb/Sales/ServiceTickets/Document/{id} | AlmohasebServiceTicketDocument | View receivables |
Almohaseb/Sales/ServiceTickets/Send/{id} (POST email, cc) | AlmohasebServiceTicketSend | Enter receivables |
The menu adds Sales > Service tickets (almohasebServiceTickets) and Sales > Invoices (almohasebInvoices, the list with kind=invoices).
Services
| Service | Lifetime | What it does |
|---|---|---|
IDailySalesService / DailySalesService | Scoped | GetAsync(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 / ContentSalesCategoryLookup | Scoped | GetAsync(ids) and ListForSalesAsync() as SalesCategoryInfo records. |
IReceivablesService / ReceivablesService | Scoped | Credit sales, customer payments, balances, statements and other income. See Receivables and other income. |
ICustomerLookup / ContentCustomerLookup | Scoped | GetNamesAsync(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 / ServiceTicketService | Scoped | Service tickets and invoices. See Service tickets and invoices. |
Permissions
| Permission | Key | Default roles |
|---|---|---|
| Enter and change the daily sales | ManageAlmohasebSales | Administrator |
| View the daily sales | ViewAlmohasebSales | Administrator |
| 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 name | Methods | Permission |
|---|---|---|---|
Almohaseb/Sales/Daily | AlmohasebDailySales | GET (date, default today's business date), POST (save and post) | View; Enter and change to save |
Almohaseb/Sales/Receivables | AlmohasebReceivables | GET (from, to, customerId; default the current month) | View receivables |
Almohaseb/Sales/Customers | AlmohasebCustomerBalances | GET | View receivables |
Almohaseb/Sales/Customers/Statement/{customerId} | AlmohasebCustomerStatement | GET | View receivables |
Almohaseb/Sales/CreditSale/Create, Almohaseb/Sales/Payment/Create | AlmohasebCreditSaleCreate, AlmohasebPaymentCreate | GET (customerId) | Enter receivables |
Almohaseb/Sales/Receivable/Edit/{id}, /Save, /Delete/{id} | AlmohasebReceivableEdit, AlmohasebReceivableSave, AlmohasebReceivableDelete | GET, POST, POST | Enter receivables |
Almohaseb/Sales/OtherIncome (+ /Create, /Edit/{id}, /Save, /Delete/{id}) | AlmohasebOtherIncome, ... | GET (from, to), editor, POST | View; 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.SaveAsyncwithDailySalesSource.PointOfSale; the register's sales tax then wins over the computed tax.