-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathinvoice.object.ts
More file actions
180 lines (172 loc) · 8.13 KB
/
Copy pathinvoice.object.ts
File metadata and controls
180 lines (172 loc) · 8.13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { ObjectSchema, Field } from '@objectstack/spec/data';
import { cel, P } from '@objectstack/spec';
/**
* Product — a small price-book / catalog. The invoice line's `product` lookup
* points here; selecting a product auto-fills the line's `description` and
* `unit_price` (the line-item grid copies matching field names from the chosen
* record — the catalog typeahead every invoicing tool has: QuickBooks
* "Product/Service", Stripe price catalog, NetSuite item column).
*/
export const Product = ObjectSchema.create({
name: 'showcase_product',
label: 'Product',
pluralLabel: 'Products',
icon: 'package',
description: 'A sellable product with a catalog price.',
fields: {
name: Field.text({ label: 'Name', required: true, searchable: true, maxLength: 120 }),
sku: Field.text({ label: 'SKU', searchable: true, maxLength: 40 }),
description: Field.text({ label: 'Description', maxLength: 200 }),
unit_price: Field.currency({ label: 'Unit Price', scale: 2, min: 0 }),
active: Field.boolean({ label: 'Active', defaultValue: true }),
},
});
/**
* Invoice + Invoice Line — the canonical master-detail "header + line items"
* shape. Unlike project↔task (a task is added to a project over time), an
* invoice is meaningless without its lines: you enter the header AND its lines
* together, in one atomic transaction. So `invoice_line.invoice` declares
* `inlineEdit: 'grid'` — every standard New/Edit Invoice form renders an
* editable line-item grid, and the invoice `total` rolls the line amounts up
* server-side. This is where inline master-detail entry belongs.
*/
export const Invoice = ObjectSchema.create({
name: 'showcase_invoice',
label: 'Invoice',
pluralLabel: 'Invoices',
icon: 'receipt',
description: 'A customer invoice entered together with its line items.',
fields: {
name: Field.text({ label: 'Invoice Number', required: true, searchable: true, maxLength: 60 }),
account: Field.lookup('showcase_account', { label: 'Account', required: true }),
// Owner email — the row-level-security anchor. The `showcase_contributor`
// permission set scopes invoice SELECT to `owner = current_user.email` (email
// is the unique, seedable identifier), so a contributor sees only their own
// invoices; because `showcase_invoice_line` is `controlled_by_parent`, the
// lines follow automatically (ADR-0055). Mirror of `project.owner`.
owner: Field.text({ label: 'Owner', maxLength: 200 }),
status: Field.select({
label: 'Status',
required: true,
options: [
{ label: 'Draft', value: 'draft', default: true, color: '#94A3B8' },
{ label: 'Sent', value: 'sent', color: '#3B82F6' },
{ label: 'Paid', value: 'paid', color: '#10B981' },
{ label: 'Void', value: 'void', color: '#EF4444' },
],
}),
// Conditional rule (B2): an invoice must carry an issue date once it leaves
// Draft. Authored as a CEL `requiredWhen`; the client makes the field show a
// required marker + blocks submit, and the server's rule-validator enforces
// the same predicate over the merged record — one rule, both ends agree.
issued_on: Field.date({
label: 'Issued On',
requiredWhen: P`record.status in ['sent', 'paid']`,
}),
// Conditional rule (B2): once an invoice is Paid, its tax rate is locked.
// `readonlyWhen` makes the client render the field read-only, and the
// server's `stripReadonlyWhenFields` drops any incoming change to it (the
// persisted value is kept) rather than rejecting the write.
// Header tax rate (percent). The line-item entry form reads it live to show
// a Subtotal / Tax / Total stack under the grid as lines are entered.
tax_rate: Field.number({
label: 'Tax Rate (%)',
min: 0,
max: 100,
defaultValue: 0,
readonlyWhen: P`record.status == 'paid'`,
}),
// Conditional rule (B2): "Paid On" is only meaningful — and only shown —
// once the invoice is Paid, and then it is required. `visibleWhen` is a
// pure client UX concern (the server has no visibility notion); the
// `requiredWhen` half is enforced on both ends.
paid_on: Field.date({
label: 'Paid On',
visibleWhen: P`record.status == 'paid'`,
requiredWhen: P`record.status == 'paid'`,
}),
// Roll-up: recomputed server-side as line items are inserted/updated/deleted
// (child FK auto-detected: showcase_invoice_line.invoice). This is the line
// subtotal; the tax-inclusive grand total is shown live during entry.
total: Field.summary({
label: 'Subtotal',
summaryOperations: { object: 'showcase_invoice_line', field: 'amount', function: 'sum' },
}),
},
});
/** Invoice line item — owned by its invoice, entered inline in the grid. */
export const InvoiceLine = ObjectSchema.create({
name: 'showcase_invoice_line',
label: 'Invoice Line',
pluralLabel: 'Invoice Lines',
icon: 'list',
description: 'A single billable line on an invoice.',
// ADR-0055: a line's access is CONTROLLED BY ITS PARENT invoice — a user sees
// and edits a line only if they can see/edit its `invoice` master. No RLS is
// authored here; the security layer derives it from the required master_detail
// relationship (`invoice`). This is the canonical master-detail use of
// controlled_by_parent (a line is meaningless apart from its invoice).
sharingModel: 'controlled_by_parent',
fields: {
invoice: Field.masterDetail('showcase_invoice', {
label: 'Invoice',
required: true,
deleteBehavior: 'cascade',
// Thin, high-volume line items → the editable grid form factor.
inlineEdit: 'grid',
inlineTitle: 'Line Items',
}),
// Catalog lookup. Picking a product auto-fills `description` + `unit_price`
// (the grid copies same-named fields from the selected product record).
// Line sort position — stamped by the grid on drag-reorder so the order
// persists. Excluded from the editable columns (it's not hand-entered).
position: Field.number({ label: 'Position', defaultValue: 0 }),
// Conditional rule (B2 in grids, PARENT-scoped): once the header invoice is
// Paid, its lines are frozen. `readonlyWhen` here references the header as
// `parent`, so the inline grid evaluates it per row against the live invoice
// record and locks the cell — the "paid invoice → lock lines" case (#1581).
product: Field.lookup('showcase_product', {
label: 'Product',
required: true,
readonlyWhen: P`parent.status == 'paid'`,
}),
// Conditional rule (B2 in grids): a bulk line (large quantity) must carry a
// description note. `requiredWhen` here is ROW-scoped — it references the
// line's own `record`, so the inline grid flags this cell required per row
// as the quantity crosses the threshold. (A header-driven lock referencing
// `parent` — see `product`/`quantity`/`unit_price` — is the parent-scoped
// counterpart; both are evaluated by the inline grid. See ADR-0036 / #1581.)
description: Field.text({
label: 'Description',
maxLength: 200,
requiredWhen: P`record.quantity >= 100`,
}),
quantity: Field.number({
label: 'Qty',
required: true,
min: 0,
defaultValue: 1,
readonlyWhen: P`parent.status == 'paid'`,
}),
unit_price: Field.currency({
label: 'Unit Price',
scale: 2,
min: 0,
readonlyWhen: P`parent.status == 'paid'`,
}),
// Amount = Qty × Unit Price. Kept as a *stored* currency column (so the
// parent Invoice.total summary can roll it up — summary aggregation reads
// stored columns, not on-read formula fields), but the `expression` makes
// the line-item grid render it READ-ONLY and recompute it live client-side
// as quantity/unit_price change, then persist the computed value. The
// server does not treat a non-`formula` field's expression as computed, so
// the client-sent value is stored as-is.
amount: Field.currency({
label: 'Amount',
scale: 2,
min: 0,
expression: cel`record.quantity * record.unit_price`,
}),
},
});