@opencxh/domain 1.240.0 → 1.241.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Three codes, three owners — and only one of them may be normalised.
3
+ *
4
+ * | Field | Whose | Shape |
5
+ * |---|---|---|
6
+ * | `sku` | ours | free, unique per organisation |
7
+ * | `gtin` | GS1's | 8, 12, 13 or 14 digits + check digit |
8
+ * | `barcodes` | mixed | as scanned |
9
+ *
10
+ * `gtin` and not `ean`: EAN-13 is one of four lengths, GTIN is the umbrella and the word GS1 uses
11
+ * itself. The screen says "EAN / barcode", because that is what people say.
12
+ */
13
+ /**
14
+ * A GTIN as one key, or `undefined` when it is not one.
15
+ *
16
+ * The same box arrives as UPC-12 `012345678905` from an American feed and as EAN-13
17
+ * `0012345678905` from Shopify. Left-padding to 14 digits makes those one key; without it the
18
+ * second import creates a second product and the reporting axis is broken.
19
+ *
20
+ * **Refuses rather than quietly cleaning up.** The check digit costs ten lines and catches the
21
+ * typo that otherwise only shows in the webshop — and a product with an invented EAN is worse
22
+ * than a product without one.
23
+ */
24
+ export declare function normalizeGtin(raw: string): string | undefined;
25
+ /**
26
+ * A filter that finds a product by whatever was typed or scanned.
27
+ *
28
+ * One helper and not three queries in three screens, because of the trap in the middle:
29
+ * `barcodes` is a string **array**, and `{ barcodes: "x" }` matches nothing on it — it has to be
30
+ * `$in`. That mistake is silent; it returns an empty list, not an error.
31
+ */
32
+ export declare function codeQuery(organizationId: string, code: string): Record<string, unknown>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export * from './codes';
2
+ export * from './pricing';
3
+ export * from './types';
@@ -0,0 +1,40 @@
1
+ import { Cents } from '../money/money';
2
+ import { PriceRule, Product } from './types';
3
+ /**
4
+ * The ladder: the most specific rule wins, whole. No sum, no stacking.
5
+ *
6
+ * | Rank | Rule | Source |
7
+ * |---|---|---|
8
+ * | 1 | contract + product | `contract` |
9
+ * | 2 | customer + product | `customer` |
10
+ * | 3 | customer + category | `customer_category` |
11
+ * | 4 | segment + product | `segment` |
12
+ * | 5 | segment + category | `segment_category` |
13
+ * | 6 | the product's list price | `list` |
14
+ *
15
+ * A tier (`minQuantity`) is not a rung of its own but a tie-break within one: of the rules that
16
+ * pass, the highest threshold that the quantity reaches wins, and the source then reads `tier`
17
+ * — because that is the answer the salesperson is looking for.
18
+ */
19
+ export interface PriceContext {
20
+ companyId?: string;
21
+ segmentKey?: string;
22
+ contractId?: string;
23
+ quantity: number;
24
+ /** Epoch ms; the validity window is judged against this, not against "now". */
25
+ at: number;
26
+ }
27
+ export interface ResolvedPrice {
28
+ unitPriceCents: Cents;
29
+ /** Where this amount came from. Almost free, and the only question a salesperson asks. */
30
+ sourceKey: string;
31
+ ruleId?: string;
32
+ }
33
+ /**
34
+ * The price for this product, for this customer, at this quantity, on this date.
35
+ *
36
+ * Pure, so the screen can compute along while you type and land on the same cent the server
37
+ * stores. A rule that names neither the product nor its category does not apply, however
38
+ * specific its customer is: a discount on "everything" is a decision, not a price rule.
39
+ */
40
+ export declare function resolvePrice(product: Product, ctx: PriceContext, rules: readonly PriceRule[]): ResolvedPrice;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,107 @@
1
+ import { Cents } from '../money/money';
2
+ /**
3
+ * What we sell, variants included.
4
+ *
5
+ * One self-referencing table instead of a product/variant pair: a variant has exactly the same
6
+ * fields as its parent and misses none of them. What it adds is {@link Product.options} — and that
7
+ * is a column, not an entity.
8
+ */
9
+ export interface Product {
10
+ id: string;
11
+ organizationId: string;
12
+ /** The parent this is a variant of. Absent = a product in its own right. */
13
+ parentId?: string;
14
+ /** What makes this variant different: `[{ key: "colour", value: "black" }]`. A label, nothing more. */
15
+ options?: {
16
+ key: string;
17
+ value: string;
18
+ }[];
19
+ name: string;
20
+ description?: string;
21
+ /** Our own article number. Unique per organisation — see `codes.ts`. */
22
+ sku?: string;
23
+ /** The global code: EAN-13, UPC-12, GTIN-14. Stored normalised — see `normalizeGtin`. */
24
+ gtin?: string;
25
+ /** Whatever else is scannable (outer case, our own Code128), as scanned. */
26
+ barcodes?: string[];
27
+ /**
28
+ * The category this falls under — a key from a small table of its own, not free text.
29
+ *
30
+ * Having to be stable is the whole requirement: together with {@link Product.taxRateKey} this is
31
+ * the key the bookkeeping bridge picks a ledger account on. A free text field where one person
32
+ * types "Services" and another "services " cannot carry that.
33
+ */
34
+ categoryKey?: string;
35
+ /** References to this product elsewhere (`moneybird:412`). A reference, never a copy. */
36
+ externalIds?: string[];
37
+ /** See {@link FIXED_UNITS}. Drives the form and the word after the quantity. */
38
+ unitKey: string;
39
+ /** Key into the organisation's VAT table, never the percentage itself. */
40
+ taxRateKey: string;
41
+ listPriceCents: Cents;
42
+ currency: string;
43
+ /**
44
+ * What we normally pay for this. **Not** a cost price out of an inventory valuation.
45
+ *
46
+ * Copied onto the line when it is added, and shielded: margin is not for everyone holding
47
+ * `sales.product.read`. It never reaches a template context either — `{line.cost}` on a quote
48
+ * would be one typo away.
49
+ */
50
+ costPriceCents?: Cents;
51
+ /** Who we buy it from. A `Company` in crm, so free — and all a later purchase order needs. */
52
+ supplierCompanyId?: string;
53
+ archived?: boolean;
54
+ order?: number;
55
+ createdBy: string;
56
+ createdAt?: number;
57
+ updatedAt?: number;
58
+ }
59
+ /**
60
+ * Units follow work's `FIXED_TYPES` pattern: a fixed list plus a namespaced fallback, so an
61
+ * unknown key reads as itself instead of breaking.
62
+ */
63
+ export declare const FIXED_UNITS: readonly ["piece", "hour", "day", "month", "km", "license"];
64
+ export type FixedUnit = (typeof FIXED_UNITS)[number];
65
+ /**
66
+ * A price for a customer, a segment or a contract.
67
+ *
68
+ * Deliberately no price expression language: no formulas, no conditions, no ordering field. The
69
+ * moment a price cannot be explained to the customer it is the wrong price.
70
+ */
71
+ export interface PriceRule {
72
+ id: string;
73
+ organizationId: string;
74
+ /** Either a specific product or a whole category. One of the two, never both. */
75
+ productId?: string;
76
+ categoryKey?: string;
77
+ /** For whom. Absent = for everyone. */
78
+ companyId?: string;
79
+ /** A free label on the customer card ("reseller", "education"). An attribute in crm. */
80
+ segmentKey?: string;
81
+ /** From which quantity. Absent = from one. */
82
+ minQuantity?: number;
83
+ /** Exactly one of the two. A fixed price beats a discount when both would apply. */
84
+ unitPriceCents?: Cents;
85
+ discountPercent?: number;
86
+ currency: string;
87
+ validFrom?: number;
88
+ validTo?: number;
89
+ /**
90
+ * The contract that placed this rule. Declared now because it is the top rung of the ladder: a
91
+ * contract price is not a second pricing mechanism, it is a price rule with an owner and a term.
92
+ */
93
+ contractId?: string;
94
+ createdBy: string;
95
+ createdAt?: number;
96
+ }
97
+ /**
98
+ * What the product form sends. Everything optional but the name: the server fills defaults, and
99
+ * a form that has to send a complete product cannot add a variant with two fields.
100
+ */
101
+ export interface SaveProductRequest extends Partial<Omit<Product, "id" | "organizationId" | "createdBy" | "createdAt" | "updatedAt">> {
102
+ id?: string;
103
+ name: string;
104
+ }
105
+ export interface SavePriceRuleRequest extends Partial<Omit<PriceRule, "id" | "organizationId" | "createdBy" | "createdAt">> {
106
+ id?: string;
107
+ }
@@ -0,0 +1,3 @@
1
+ export * from './money';
2
+ export * from './tax';
3
+ export * from './totals';
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Money, as whole cents. There is no `number` in euros anywhere in this platform.
3
+ *
4
+ * Not caution but necessity: `0.1 + 0.2 !== 0.3`, and a quote of eleven lines adds up eleven
5
+ * times. One cent between the screen and the invoice is not a rounding error, it is a customer
6
+ * on the phone.
7
+ *
8
+ * The currency lives on the transaction, never on the line: adding lines in two currencies does
9
+ * not produce an amount.
10
+ */
11
+ export type Cents = number;
12
+ /**
13
+ * Half a cent goes up, below zero too.
14
+ *
15
+ * `Math.round(-0.5)` is `-0`, not `-1`: JS rounds towards +infinity. On a credit note — where
16
+ * every amount is negative — that is systematically one cent too little refunded per line, and it
17
+ * only shows when someone puts the credit note next to the invoice.
18
+ */
19
+ export declare function roundHalfUp(value: number): Cents;
20
+ /**
21
+ * Split `total` over `weights` so the parts sum to exactly `total`.
22
+ *
23
+ * VAT is computed per rate group and not per line (see `totals.ts`), but a journal entry books it
24
+ * per line. Dividing proportionally and rounding each part independently loses or gains cents;
25
+ * largest-remainder gives every part its floor and hands the leftover cents to the largest
26
+ * fractions. Works for negative totals — a credit note is the whole point.
27
+ *
28
+ * Weights that sum to zero cannot be divided proportionally, so the whole amount lands on the
29
+ * first part rather than disappearing.
30
+ */
31
+ export declare function allocate(total: Cents, weights: readonly number[]): Cents[];
32
+ /** Grouping and decimal marks. Defaults are Dutch, because the organisation is. */
33
+ export interface AmountFormat {
34
+ decimal?: string;
35
+ group?: string;
36
+ }
37
+ /**
38
+ * Cents as a readable amount. Always two decimals, never a currency symbol.
39
+ *
40
+ * Hand-rolled instead of `Intl.NumberFormat`: this module is imported by `apps/*​/server`, which
41
+ * runs on QuickJS, and `Intl` is not something to bet an invoice on. The symbol is the caller's
42
+ * job — it belongs next to the amount in the UI, not inside the number.
43
+ */
44
+ export declare function formatCents(cents: Cents, format?: AmountFormat): string;
45
+ /**
46
+ * A typed amount to cents, or `undefined` when it is not an amount.
47
+ *
48
+ * Both "1.234,56" and "1,234.56" have to work, and they differ only in which separator came last.
49
+ * So: the **last** separator is the decimal mark when one or two digits follow it; anything else
50
+ * is grouping and gets dropped. "1.234" is therefore one thousand two hundred and thirty-four,
51
+ * not 1,234 — the reading a Dutch typist intends, and the one a three-digit group implies.
52
+ *
53
+ * `undefined` rather than `0` on junk: a silent zero is a line that quietly costs nothing.
54
+ */
55
+ export declare function parseAmount(input: string): Cents | undefined;
56
+ /**
57
+ * What a line earns: its net minus what the goods cost us.
58
+ *
59
+ * Derived, never stored — see the plan's §2.2c. A line without a cost price counts as zero margin
60
+ * and not as a loss; "unknown" and "free" are not the same thing, and the screen says which.
61
+ */
62
+ export declare function marginCents(line: {
63
+ netCents: Cents;
64
+ quantity: number;
65
+ costPriceCents?: Cents;
66
+ }): Cents;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ /**
2
+ * VAT rates: a table, not an engine.
3
+ *
4
+ * The rate lives on the organisation's settings and a line refers to it **by key**, never by
5
+ * percentage. A rate that changes (21 became 21 in 2019, 19 before that) must not rewrite history,
6
+ * and a percentage copied onto a thousand lines cannot be corrected.
7
+ */
8
+ export interface TaxRate {
9
+ key: string;
10
+ /** i18n key, not text — the same rule as `FIXED_TYPES` in work. */
11
+ label: string;
12
+ /** Whole percent as written on the invoice: `21`, not `0.21`. */
13
+ percent: number;
14
+ kind: TaxRateKind;
15
+ }
16
+ /**
17
+ * Why a rate is what it is.
18
+ *
19
+ * `zero`, `reverse_charge` and `exempt` all compute to nothing and are still three different
20
+ * sentences on an invoice, which is the only reason they are separate: the template picks its
21
+ * line from this, and the bookkeeping connector maps it onto its own tax code.
22
+ */
23
+ export type TaxRateKind = "standard" | "reduced" | "zero" | "reverse_charge" | "exempt";
24
+ /** The Dutch defaults an organisation starts with. It may edit them; it may not compute them. */
25
+ export declare const NL_TAX_RATES: readonly TaxRate[];
26
+ /**
27
+ * The rate for a key, or a zero-percent stand-in.
28
+ *
29
+ * Never `undefined`: an unknown key on a saved line must not make a total uncomputable. Charging
30
+ * nothing is the safe direction — too little VAT is a correction, a crash is a document nobody
31
+ * can open.
32
+ */
33
+ export declare function taxRateFor(key: string, rates: readonly TaxRate[]): TaxRate;
@@ -0,0 +1,34 @@
1
+ import { TransactionLine, TransactionTotals } from '../transaction/types';
2
+ import { Cents } from './money';
3
+ import { TaxRate } from './tax';
4
+ /**
5
+ * The rounding order. Fixed, and not a setting.
6
+ *
7
+ * 1. Line net = `roundHalfUp(quantity * unitPrice * (1 - discount))` — once, per line.
8
+ * 2. Net per rate = the sum of those line amounts. Exact, they are already integers.
9
+ * 3. VAT = `roundHalfUp(netPerRate * rate)` — **once per rate, not per line.**
10
+ * 4. Total = sum of nets + sum of VAT.
11
+ *
12
+ * Step 3 is where billing packages diverge. Rounding per line differs by up to ten cents on twenty
13
+ * lines from every bookkeeping system that rounds per rate group — which is what Moneybird, Exact
14
+ * and the tax office do.
15
+ *
16
+ * `quantity` may be fractional (3.5 hours); a *price* never is.
17
+ */
18
+ export declare function lineNet(line: TransactionLine): Cents;
19
+ export interface PricedTransaction {
20
+ /** The same lines with `netCents` and `taxCents` filled in. */
21
+ lines: TransactionLine[];
22
+ totals: TransactionTotals;
23
+ }
24
+ /**
25
+ * The four steps, plus the one thing they leave open: VAT per line.
26
+ *
27
+ * The group amount from step 3 is handed back to its own lines with {@link allocate}, so
28
+ * `sum(line.taxCents) === totals.taxCents` exactly. A journal entry books VAT per line, and a
29
+ * split that is off by a cent is a journal entry that does not balance.
30
+ *
31
+ * `overrideKey` is the "reverse charge / exempt" tick on the transaction: it replaces every line's
32
+ * rate, because that is a property of the sale and not of the product.
33
+ */
34
+ export declare function transactionTotals(lines: readonly TransactionLine[], rates: readonly TaxRate[], overrideKey?: string): PricedTransaction;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import { Transaction } from './types';
2
+ /** A transaction with `issuedAt` is frozen. One question, one answer, one place. */
3
+ export declare function isFrozen(transaction: Pick<Transaction, "issuedAt">): boolean;
4
+ /**
5
+ * What a frozen transaction still accepts.
6
+ *
7
+ * `status`, `fileRef` and `dunning` are what happens *to* a document after it left. `number` and
8
+ * `externalIds` are there because Moneybird, Exact and AFAS issue the invoice number themselves
9
+ * and it arrives after issuing — a freeze rule that refuses that forces the bookkeeping bridge
10
+ * into a second column, and then the real invoice number is not on the invoice. `postedAt` is the
11
+ * push marking its own work done.
12
+ *
13
+ * Everything else is a `BadRequestError`. Whoever wants to change a sent quote gets v2.
14
+ */
15
+ export declare const FROZEN_WRITABLE_FIELDS: readonly (keyof Transaction)[];
16
+ /** The fields a draft carries over; the server owns id, organisation and author. */
17
+ export type TransactionDraft = Omit<Transaction, "id" | "organizationId" | "createdBy" | "createdAt" | "updatedAt">;
18
+ /**
19
+ * The conversion: quote -> order -> invoice -> credit note, as one function.
20
+ *
21
+ * Amounts are not copied but recomputed by the caller (`transactionTotals`), because the target
22
+ * kind may invert them. Inverting happens on **`quantity`** and not on the price, so the one
23
+ * formula in `lineNet` keeps holding and a negative amount stays the outcome of a calculation
24
+ * rather than a second code path.
25
+ *
26
+ * What is deliberately dropped: the number, the issue date, the file, the external ids and the
27
+ * dunning history. Those belong to the document that went out, and this is a new one.
28
+ */
29
+ export declare function copyFrom(source: Transaction, kindKey: string): TransactionDraft;
30
+ /**
31
+ * The next version of the same quote.
32
+ *
33
+ * Whoever wants to change a sent quote does not edit it: the previous one stays, read-only, and
34
+ * stays retrievable — because that is what the customer saw. Easy to build, painful to introduce
35
+ * afterwards: without this rule you have, a year from now, a quote that was changed after it was
36
+ * signed and no way left to see that.
37
+ */
38
+ export declare function nextVersion(source: Transaction): TransactionDraft;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ export * from './convert';
2
+ export * from './keys';
3
+ export * from './kinds';
4
+ export * from './types';
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The scope kinds `apps/sales` owns — **the kinds, not the engine**.
3
+ *
4
+ * Every other app in this platform claims concrete nouns: `contact` + `company` (crm),
5
+ * `interaction` + `activity` (comms), `artifact` + `file` (storage). More importantly a scopeKey
6
+ * is *stored* — in relation edges, attributes, memory rows, pins. `invoice:412` stays
7
+ * `invoice:412` even if invoices ever move to a finance app; one branded `sales_document:412`
8
+ * would mean a backfill across four apps.
9
+ */
10
+ export declare const TRANSACTION_SCOPE_KINDS: readonly string[];
11
+ export declare const PRODUCT_SCOPE_KIND = "product";
12
+ /** `invoice:412`. The kind of a transaction *is* its kind key — no mapping table. */
13
+ export declare function transactionScopeKey(kindKey: string, id: string): string;
14
+ export declare function productScopeKey(id: string): string;
15
+ /** Is this scopeKey one of ours, and which id does it point at? */
16
+ export declare function transactionIdFromScope(scopeKey: string): string | undefined;
@@ -0,0 +1,32 @@
1
+ import { TransactionStatus } from './types';
2
+ /**
3
+ * A kind is a row of flags, not a subclass.
4
+ *
5
+ * Switching on `order` and `invoice` later is a row here plus a number series — no second screen,
6
+ * no second line model, no second renderer. That is the whole reason to build the engine generic
7
+ * in one go.
8
+ *
9
+ * There is no `direction`: everything `apps/sales` issues goes outward. A purchase invoice is not
10
+ * a sale — no source quote, a different approval flow, a different user — and belongs in a finance
11
+ * app, on the same type and its own table. A flag that is always `out` in one app and always `in`
12
+ * in the other is an app boundary, not a flag.
13
+ */
14
+ export interface TransactionKind {
15
+ key: string;
16
+ /** i18n key, not text. */
17
+ label: string;
18
+ /** Gets a number from a series when issued. */
19
+ numbered: boolean;
20
+ /** Invert amounts: a credit note is an invoice with a minus sign. */
21
+ signFlip: boolean;
22
+ statuses: readonly TransactionStatus[];
23
+ }
24
+ export declare const TRANSACTION_KINDS: readonly TransactionKind[];
25
+ /**
26
+ * The kind for a key, or a safe stand-in.
27
+ *
28
+ * Never `undefined`: a stored row with a kind we no longer know must still open. The stand-in is
29
+ * the conservative one — unnumbered and unflipped, because handing out a number or inverting
30
+ * amounts on a guess is worse than showing a document with a strange label.
31
+ */
32
+ export declare function transactionKind(key: string): TransactionKind;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,126 @@
1
+ import { Cents } from '../money/money';
2
+ import { OwnerScope } from '../scope/types';
3
+ /**
4
+ * The engine: a quote, an order, an invoice, a credit note.
5
+ *
6
+ * Deliberately **not** called a document. In this repo `Document*` means the sheet of paper —
7
+ * blocks, templates, slots — and that layer renders a contract as readily as an invoice. A
8
+ * transaction *has* a document; it is not one. See `plans/SALES-2026-09-14.md` §2.0.
9
+ */
10
+ export interface Transaction {
11
+ id: string;
12
+ organizationId: string;
13
+ /** Which kind. See {@link TRANSACTION_KINDS}. In v1 only `quote` is switched on. */
14
+ kindKey: string;
15
+ /**
16
+ * The transaction this one came out of: quote -> order -> invoice.
17
+ *
18
+ * This one field *is* the conversion. `copyFrom()` carries over the lines, the customer, the
19
+ * currency and the price sources; there is no second code path per direction, and "which
20
+ * invoice belongs to this quote" is an indexed query instead of a hunt.
21
+ */
22
+ sourceTransactionId?: string;
23
+ /** The agreement that produced it, when it came from one. */
24
+ agreementId?: string;
25
+ /** Version within the same quote. A sent quote is revised, never edited. */
26
+ version: number;
27
+ previousVersionId?: string;
28
+ companyId: string;
29
+ contactId?: string;
30
+ /** The work item (the deal) this belongs to. Optional: not every quote has a deal. */
31
+ dealItemId?: string;
32
+ ownerScope: OwnerScope;
33
+ ownerUserId: string;
34
+ title?: string;
35
+ reference?: string;
36
+ currency: string;
37
+ status: TransactionStatus;
38
+ /**
39
+ * The lines, as a column and not a table.
40
+ *
41
+ * A transaction is always read whole and never written half — and freezing has to be
42
+ * indivisible, which two tables without a transaction cannot do. The price is that "revenue per
43
+ * product" is not a query; that question is answered from the facts written at issue time.
44
+ */
45
+ lines: TransactionLine[];
46
+ /** Recomputed on every save, stored so a list can sort and add up. */
47
+ totals: TransactionTotals;
48
+ /** Reverse charge or exemption: ticked, never derived. Replaces every line's rate. */
49
+ taxOverrideKey?: string;
50
+ introText?: string;
51
+ termsText?: string;
52
+ /** Epoch ms. `issuedAt` set = frozen. */
53
+ issuedAt?: number;
54
+ expiresAt?: number;
55
+ /** Only on a numbered kind, and only at the moment of issuing. */
56
+ number?: string;
57
+ /** Posted — to whichever set of books. Set by the push, never by a person. */
58
+ postedAt?: number;
59
+ /** The rendered PDF in `apps/storage`. A reference, never the content. */
60
+ fileRef?: {
61
+ mountId: string;
62
+ fileId: string;
63
+ };
64
+ /** Which dunning step went out when. This is the whole debtor state; there is no table. */
65
+ dunning?: {
66
+ level: number;
67
+ sentAt: number;
68
+ }[];
69
+ /**
70
+ * This transaction in external systems (`moneybird:412`). The dedupe axis the ingest pipeline
71
+ * upserts on — without it the second run of an order import creates every order again.
72
+ */
73
+ externalIds?: string[];
74
+ /** Which credential a change has to be written back with. Two connections, two answers. */
75
+ source?: {
76
+ connectionId?: string;
77
+ };
78
+ createdBy: string;
79
+ createdAt?: number;
80
+ updatedAt?: number;
81
+ }
82
+ /**
83
+ * The statuses, all kinds together. Which kind offers which is {@link TransactionKind.statuses}.
84
+ *
85
+ * A union and not a free string: kinds and their statuses are **ours**, not the organisation's.
86
+ * Labels follow from the key as `status_<key>`.
87
+ */
88
+ export type TransactionStatus = "draft" | "sent" | "accepted" | "rejected" | "expired" | "confirmed" | "delivered" | "open" | "paid" | "overdue" | "settled" | "cancelled";
89
+ export interface TransactionLine {
90
+ /** Stable within the transaction, so a version diff can follow a line. */
91
+ id: string;
92
+ /** `product` counts; `text` is a heading or a paragraph and carries no amount. */
93
+ kind: "product" | "text";
94
+ productId?: string;
95
+ /** Copied on adding: a quote from last year must stay readable when the product is renamed. */
96
+ name: string;
97
+ description?: string;
98
+ unitKey: string;
99
+ quantity: number;
100
+ unitPriceCents: Cents;
101
+ discountPercent?: number;
102
+ taxRateKey: string;
103
+ /** Which rung of the price ladder produced this amount. Purely to account for it. */
104
+ priceSourceKey?: string;
105
+ /** Step 1 of the rounding order, stored so the client need not recompute. */
106
+ netCents: Cents;
107
+ /** This line's share of its rate group's VAT. Shielded, like the cost price. */
108
+ taxCents: Cents;
109
+ /** The cost price as it stood when the line was added. Shielded by `sales.product.cost`. */
110
+ costPriceCents?: Cents;
111
+ /** The mapping axis onto a chart of accounts, copied from the product's category. */
112
+ ledgerCategoryKey?: string;
113
+ }
114
+ export interface TransactionTotals {
115
+ netCents: Cents;
116
+ taxCents: Cents;
117
+ grossCents: Cents;
118
+ /** Sum of the copied cost prices. Shielded — strip it with the line's `costPriceCents`. */
119
+ costCents?: Cents;
120
+ /** Per rate, because that is what the invoice and the bookkeeping both need. */
121
+ byRate: {
122
+ taxRateKey: string;
123
+ netCents: Cents;
124
+ taxCents: Cents;
125
+ }[];
126
+ }