@pylonsync/create-pylon 0.3.344 → 0.3.346
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.
- package/bin/create-pylon.js +35 -0
- package/package.json +1 -1
- package/templates/crm/AGENTS.md +116 -0
- package/templates/crm/CLAUDE.md +7 -0
- package/templates/crm/README.md +96 -0
- package/templates/crm/app/companies/companies-view.tsx +129 -0
- package/templates/crm/app/companies/page.tsx +17 -0
- package/templates/crm/app/contacts/contacts-view.tsx +139 -0
- package/templates/crm/app/contacts/page.tsx +17 -0
- package/templates/crm/app/deals/[id]/deal-view.tsx +189 -0
- package/templates/crm/app/deals/[id]/page.tsx +23 -0
- package/templates/crm/app/error.tsx +43 -0
- package/templates/crm/app/globals.css +201 -0
- package/templates/crm/app/layout.tsx +31 -0
- package/templates/crm/app/login/auth-form.tsx +122 -0
- package/templates/crm/app/login/page.tsx +38 -0
- package/templates/crm/app/not-found.tsx +29 -0
- package/templates/crm/app/page.tsx +38 -0
- package/templates/crm/app/pipeline-view.tsx +99 -0
- package/templates/crm/app/robots.ts +12 -0
- package/templates/crm/app/sitemap.ts +27 -0
- package/templates/crm/app/workspace.tsx +231 -0
- package/templates/crm/app.ts +171 -0
- package/templates/crm/bunfig +4 -0
- package/templates/crm/components/activity-timeline.tsx +133 -0
- package/templates/crm/components/avatar.tsx +40 -0
- package/templates/crm/components/board-skeleton.tsx +44 -0
- package/templates/crm/components/command-palette.tsx +162 -0
- package/templates/crm/components/data-table.tsx +90 -0
- package/templates/crm/components/deal-card.tsx +91 -0
- package/templates/crm/components/deal-dialog.tsx +169 -0
- package/templates/crm/components/empty-state.tsx +43 -0
- package/templates/crm/components/kbd.tsx +23 -0
- package/templates/crm/components/metrics-bar.tsx +46 -0
- package/templates/crm/components/page-header.tsx +36 -0
- package/templates/crm/components/pipeline-board.tsx +105 -0
- package/templates/crm/components/record-dialog.tsx +126 -0
- package/templates/crm/components/sidebar.tsx +112 -0
- package/templates/crm/components/stage-badge.tsx +33 -0
- package/templates/crm/components/ui/badge.tsx +37 -0
- package/templates/crm/components/ui/button.tsx +56 -0
- package/templates/crm/components/ui/card.tsx +90 -0
- package/templates/crm/components/ui/dialog.tsx +110 -0
- package/templates/crm/components/ui/input.tsx +30 -0
- package/templates/crm/components/ui/label.tsx +21 -0
- package/templates/crm/components/ui/select.tsx +39 -0
- package/templates/crm/components/ui/table.tsx +124 -0
- package/templates/crm/components/ui/textarea.tsx +24 -0
- package/templates/crm/components.json +20 -0
- package/templates/crm/functions/createDeal.ts +59 -0
- package/templates/crm/functions/logActivity.ts +49 -0
- package/templates/crm/functions/moveDeal.ts +53 -0
- package/templates/crm/functions/seedWorkspace.ts +63 -0
- package/templates/crm/gitignore +10 -0
- package/templates/crm/lib/pipeline.ts +221 -0
- package/templates/crm/lib/search.ts +78 -0
- package/templates/crm/lib/seed.ts +130 -0
- package/templates/crm/lib/utils.ts +10 -0
- package/templates/crm/package.json +39 -0
- package/templates/crm/tests/components.test.tsx +245 -0
- package/templates/crm/tests/pipeline.test.ts +219 -0
- package/templates/crm/tests/search.test.ts +83 -0
- package/templates/crm/tests/setup.ts +5 -0
- package/templates/crm/tsconfig.json +33 -0
- package/templates/helpdesk/AGENTS.md +116 -0
- package/templates/helpdesk/CLAUDE.md +7 -0
- package/templates/helpdesk/README.md +90 -0
- package/templates/helpdesk/app/customers/customers-view.tsx +140 -0
- package/templates/helpdesk/app/customers/page.tsx +17 -0
- package/templates/helpdesk/app/error.tsx +43 -0
- package/templates/helpdesk/app/globals.css +201 -0
- package/templates/helpdesk/app/inbox-view.tsx +147 -0
- package/templates/helpdesk/app/layout.tsx +31 -0
- package/templates/helpdesk/app/login/auth-form.tsx +122 -0
- package/templates/helpdesk/app/login/page.tsx +38 -0
- package/templates/helpdesk/app/not-found.tsx +29 -0
- package/templates/helpdesk/app/page.tsx +38 -0
- package/templates/helpdesk/app/robots.ts +12 -0
- package/templates/helpdesk/app/sitemap.ts +27 -0
- package/templates/helpdesk/app/tickets/[id]/page.tsx +23 -0
- package/templates/helpdesk/app/tickets/[id]/ticket-view.tsx +164 -0
- package/templates/helpdesk/app/workspace.tsx +210 -0
- package/templates/helpdesk/app.ts +142 -0
- package/templates/helpdesk/bunfig +4 -0
- package/templates/helpdesk/components/avatar.tsx +40 -0
- package/templates/helpdesk/components/command-palette.tsx +162 -0
- package/templates/helpdesk/components/data-table.tsx +90 -0
- package/templates/helpdesk/components/empty-state.tsx +43 -0
- package/templates/helpdesk/components/kbd.tsx +23 -0
- package/templates/helpdesk/components/new-ticket-dialog.tsx +147 -0
- package/templates/helpdesk/components/page-header.tsx +36 -0
- package/templates/helpdesk/components/priority-badge.tsx +61 -0
- package/templates/helpdesk/components/record-dialog.tsx +126 -0
- package/templates/helpdesk/components/sidebar.tsx +111 -0
- package/templates/helpdesk/components/sla-indicator.tsx +47 -0
- package/templates/helpdesk/components/thread.tsx +143 -0
- package/templates/helpdesk/components/ticket-list.tsx +78 -0
- package/templates/helpdesk/components/ui/badge.tsx +37 -0
- package/templates/helpdesk/components/ui/button.tsx +56 -0
- package/templates/helpdesk/components/ui/card.tsx +90 -0
- package/templates/helpdesk/components/ui/dialog.tsx +110 -0
- package/templates/helpdesk/components/ui/input.tsx +30 -0
- package/templates/helpdesk/components/ui/label.tsx +21 -0
- package/templates/helpdesk/components/ui/select.tsx +39 -0
- package/templates/helpdesk/components/ui/table.tsx +124 -0
- package/templates/helpdesk/components/ui/textarea.tsx +24 -0
- package/templates/helpdesk/components.json +20 -0
- package/templates/helpdesk/functions/createTicket.ts +62 -0
- package/templates/helpdesk/functions/replyToTicket.ts +55 -0
- package/templates/helpdesk/functions/seedWorkspace.ts +52 -0
- package/templates/helpdesk/functions/setTicketState.ts +58 -0
- package/templates/helpdesk/gitignore +10 -0
- package/templates/helpdesk/lib/format.ts +53 -0
- package/templates/helpdesk/lib/search.ts +78 -0
- package/templates/helpdesk/lib/seed.ts +185 -0
- package/templates/helpdesk/lib/tickets.ts +184 -0
- package/templates/helpdesk/lib/utils.ts +10 -0
- package/templates/helpdesk/package.json +39 -0
- package/templates/helpdesk/tests/components.test.tsx +202 -0
- package/templates/helpdesk/tests/search.test.ts +83 -0
- package/templates/helpdesk/tests/setup.ts +5 -0
- package/templates/helpdesk/tests/tickets.test.ts +215 -0
- package/templates/helpdesk/tsconfig.json +33 -0
- package/templates/inventory/AGENTS.md +116 -0
- package/templates/inventory/CLAUDE.md +7 -0
- package/templates/inventory/README.md +103 -0
- package/templates/inventory/app/error.tsx +43 -0
- package/templates/inventory/app/globals.css +201 -0
- package/templates/inventory/app/layout.tsx +31 -0
- package/templates/inventory/app/login/auth-form.tsx +122 -0
- package/templates/inventory/app/login/page.tsx +38 -0
- package/templates/inventory/app/movements/movements-view.tsx +103 -0
- package/templates/inventory/app/movements/page.tsx +17 -0
- package/templates/inventory/app/not-found.tsx +29 -0
- package/templates/inventory/app/page.tsx +34 -0
- package/templates/inventory/app/products/[id]/page.tsx +23 -0
- package/templates/inventory/app/products/[id]/product-view.tsx +178 -0
- package/templates/inventory/app/products-view.tsx +213 -0
- package/templates/inventory/app/robots.ts +12 -0
- package/templates/inventory/app/sitemap.ts +27 -0
- package/templates/inventory/app/workspace.tsx +172 -0
- package/templates/inventory/app.ts +154 -0
- package/templates/inventory/bunfig +4 -0
- package/templates/inventory/components/avatar.tsx +40 -0
- package/templates/inventory/components/command-palette.tsx +162 -0
- package/templates/inventory/components/data-table.tsx +90 -0
- package/templates/inventory/components/empty-state.tsx +43 -0
- package/templates/inventory/components/kbd.tsx +23 -0
- package/templates/inventory/components/movement-dialog.tsx +182 -0
- package/templates/inventory/components/page-header.tsx +36 -0
- package/templates/inventory/components/record-dialog.tsx +126 -0
- package/templates/inventory/components/sidebar.tsx +111 -0
- package/templates/inventory/components/stock-level.tsx +47 -0
- package/templates/inventory/components/summary-bar.tsx +56 -0
- package/templates/inventory/components/ui/badge.tsx +37 -0
- package/templates/inventory/components/ui/button.tsx +56 -0
- package/templates/inventory/components/ui/card.tsx +90 -0
- package/templates/inventory/components/ui/dialog.tsx +110 -0
- package/templates/inventory/components/ui/input.tsx +30 -0
- package/templates/inventory/components/ui/label.tsx +21 -0
- package/templates/inventory/components/ui/select.tsx +39 -0
- package/templates/inventory/components/ui/table.tsx +124 -0
- package/templates/inventory/components/ui/textarea.tsx +24 -0
- package/templates/inventory/components.json +20 -0
- package/templates/inventory/functions/recordMovement.ts +72 -0
- package/templates/inventory/functions/seedWorkspace.ts +40 -0
- package/templates/inventory/gitignore +10 -0
- package/templates/inventory/lib/format.ts +53 -0
- package/templates/inventory/lib/search.ts +78 -0
- package/templates/inventory/lib/seed.ts +102 -0
- package/templates/inventory/lib/stock.ts +207 -0
- package/templates/inventory/lib/utils.ts +10 -0
- package/templates/inventory/package.json +39 -0
- package/templates/inventory/tests/components.test.tsx +155 -0
- package/templates/inventory/tests/search.test.ts +83 -0
- package/templates/inventory/tests/setup.ts +5 -0
- package/templates/inventory/tests/stock.test.ts +255 -0
- package/templates/inventory/tsconfig.json +33 -0
- package/templates/invoices/AGENTS.md +116 -0
- package/templates/invoices/CLAUDE.md +7 -0
- package/templates/invoices/README.md +99 -0
- package/templates/invoices/app/clients/clients-view.tsx +130 -0
- package/templates/invoices/app/clients/page.tsx +17 -0
- package/templates/invoices/app/error.tsx +43 -0
- package/templates/invoices/app/globals.css +201 -0
- package/templates/invoices/app/invoices/[id]/invoice-view.tsx +253 -0
- package/templates/invoices/app/invoices/[id]/page.tsx +23 -0
- package/templates/invoices/app/invoices-view.tsx +183 -0
- package/templates/invoices/app/layout.tsx +31 -0
- package/templates/invoices/app/login/auth-form.tsx +122 -0
- package/templates/invoices/app/login/page.tsx +38 -0
- package/templates/invoices/app/not-found.tsx +29 -0
- package/templates/invoices/app/page.tsx +36 -0
- package/templates/invoices/app/robots.ts +12 -0
- package/templates/invoices/app/sitemap.ts +27 -0
- package/templates/invoices/app/workspace.tsx +174 -0
- package/templates/invoices/app.ts +169 -0
- package/templates/invoices/bunfig +4 -0
- package/templates/invoices/components/avatar.tsx +40 -0
- package/templates/invoices/components/command-palette.tsx +162 -0
- package/templates/invoices/components/data-table.tsx +90 -0
- package/templates/invoices/components/empty-state.tsx +43 -0
- package/templates/invoices/components/kbd.tsx +23 -0
- package/templates/invoices/components/line-items.tsx +172 -0
- package/templates/invoices/components/page-header.tsx +36 -0
- package/templates/invoices/components/payment-dialog.tsx +132 -0
- package/templates/invoices/components/record-dialog.tsx +126 -0
- package/templates/invoices/components/sidebar.tsx +111 -0
- package/templates/invoices/components/status-badge.tsx +35 -0
- package/templates/invoices/components/summary-bar.tsx +65 -0
- package/templates/invoices/components/totals-panel.tsx +70 -0
- package/templates/invoices/components/ui/badge.tsx +37 -0
- package/templates/invoices/components/ui/button.tsx +56 -0
- package/templates/invoices/components/ui/card.tsx +90 -0
- package/templates/invoices/components/ui/dialog.tsx +110 -0
- package/templates/invoices/components/ui/input.tsx +30 -0
- package/templates/invoices/components/ui/label.tsx +21 -0
- package/templates/invoices/components/ui/select.tsx +39 -0
- package/templates/invoices/components/ui/table.tsx +124 -0
- package/templates/invoices/components/ui/textarea.tsx +24 -0
- package/templates/invoices/components.json +20 -0
- package/templates/invoices/functions/createInvoice.ts +49 -0
- package/templates/invoices/functions/recordPayment.ts +72 -0
- package/templates/invoices/functions/seedWorkspace.ts +57 -0
- package/templates/invoices/functions/setInvoiceStatus.ts +35 -0
- package/templates/invoices/gitignore +10 -0
- package/templates/invoices/lib/billing.ts +259 -0
- package/templates/invoices/lib/format.ts +53 -0
- package/templates/invoices/lib/search.ts +78 -0
- package/templates/invoices/lib/seed.ts +190 -0
- package/templates/invoices/lib/utils.ts +10 -0
- package/templates/invoices/package.json +39 -0
- package/templates/invoices/tests/billing.test.ts +283 -0
- package/templates/invoices/tests/components.test.tsx +172 -0
- package/templates/invoices/tests/search.test.ts +83 -0
- package/templates/invoices/tests/setup.ts +5 -0
- package/templates/invoices/tsconfig.json +33 -0
- package/templates/projects/AGENTS.md +116 -0
- package/templates/projects/CLAUDE.md +7 -0
- package/templates/projects/README.md +96 -0
- package/templates/projects/app/clients/clients-view.tsx +126 -0
- package/templates/projects/app/clients/page.tsx +17 -0
- package/templates/projects/app/error.tsx +43 -0
- package/templates/projects/app/globals.css +201 -0
- package/templates/projects/app/layout.tsx +31 -0
- package/templates/projects/app/login/auth-form.tsx +122 -0
- package/templates/projects/app/login/page.tsx +38 -0
- package/templates/projects/app/not-found.tsx +29 -0
- package/templates/projects/app/page.tsx +30 -0
- package/templates/projects/app/projects/[id]/page.tsx +23 -0
- package/templates/projects/app/projects/[id]/project-view.tsx +179 -0
- package/templates/projects/app/projects-view.tsx +181 -0
- package/templates/projects/app/robots.ts +12 -0
- package/templates/projects/app/sitemap.ts +27 -0
- package/templates/projects/app/workspace.tsx +190 -0
- package/templates/projects/app.ts +186 -0
- package/templates/projects/bunfig +4 -0
- package/templates/projects/components/avatar.tsx +40 -0
- package/templates/projects/components/board-skeleton.tsx +37 -0
- package/templates/projects/components/budget-bar.tsx +61 -0
- package/templates/projects/components/command-palette.tsx +162 -0
- package/templates/projects/components/data-table.tsx +90 -0
- package/templates/projects/components/empty-state.tsx +43 -0
- package/templates/projects/components/kbd.tsx +23 -0
- package/templates/projects/components/page-header.tsx +36 -0
- package/templates/projects/components/record-dialog.tsx +126 -0
- package/templates/projects/components/sidebar.tsx +111 -0
- package/templates/projects/components/task-board.tsx +109 -0
- package/templates/projects/components/task-card.tsx +77 -0
- package/templates/projects/components/time-dialog.tsx +114 -0
- package/templates/projects/components/ui/badge.tsx +37 -0
- package/templates/projects/components/ui/button.tsx +56 -0
- package/templates/projects/components/ui/card.tsx +90 -0
- package/templates/projects/components/ui/dialog.tsx +110 -0
- package/templates/projects/components/ui/input.tsx +30 -0
- package/templates/projects/components/ui/label.tsx +21 -0
- package/templates/projects/components/ui/select.tsx +39 -0
- package/templates/projects/components/ui/table.tsx +124 -0
- package/templates/projects/components/ui/textarea.tsx +24 -0
- package/templates/projects/components.json +20 -0
- package/templates/projects/functions/logTime.ts +49 -0
- package/templates/projects/functions/moveTask.ts +40 -0
- package/templates/projects/functions/seedWorkspace.ts +59 -0
- package/templates/projects/gitignore +10 -0
- package/templates/projects/lib/format.ts +53 -0
- package/templates/projects/lib/search.ts +78 -0
- package/templates/projects/lib/seed.ts +140 -0
- package/templates/projects/lib/utils.ts +10 -0
- package/templates/projects/lib/work.ts +233 -0
- package/templates/projects/package.json +39 -0
- package/templates/projects/tests/components.test.tsx +200 -0
- package/templates/projects/tests/search.test.ts +83 -0
- package/templates/projects/tests/setup.ts +5 -0
- package/templates/projects/tests/work.test.ts +238 -0
- package/templates/projects/tsconfig.json +33 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
REASONS,
|
|
4
|
+
isValidReason,
|
|
5
|
+
money,
|
|
6
|
+
needsReorder,
|
|
7
|
+
onHand,
|
|
8
|
+
onHandByProduct,
|
|
9
|
+
parseAmount,
|
|
10
|
+
parseCount,
|
|
11
|
+
reasonAllows,
|
|
12
|
+
signedQuantity,
|
|
13
|
+
stockState,
|
|
14
|
+
summarize,
|
|
15
|
+
type Movement,
|
|
16
|
+
type Product,
|
|
17
|
+
} from "../lib/stock";
|
|
18
|
+
|
|
19
|
+
const product = (over: Partial<Product> = {}): Product => ({
|
|
20
|
+
id: "p1",
|
|
21
|
+
sku: "SKU-1",
|
|
22
|
+
name: "Thing",
|
|
23
|
+
unitCostCents: 1000,
|
|
24
|
+
reorderPoint: 5,
|
|
25
|
+
...over,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const move = (over: Partial<Movement> = {}): Movement => ({
|
|
29
|
+
id: "m1",
|
|
30
|
+
productId: "p1",
|
|
31
|
+
delta: 10,
|
|
32
|
+
reason: "received",
|
|
33
|
+
...over,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("onHand", () => {
|
|
37
|
+
test("is the sum of the ledger, not a stored number", () => {
|
|
38
|
+
expect(
|
|
39
|
+
onHand("p1", [
|
|
40
|
+
move({ id: "a", delta: 60 }),
|
|
41
|
+
move({ id: "b", delta: -8 }),
|
|
42
|
+
move({ id: "c", delta: -11 }),
|
|
43
|
+
]),
|
|
44
|
+
).toBe(41);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("two concurrent receipts both count", () => {
|
|
48
|
+
// The bug this design exists to prevent: with a mutable column, both
|
|
49
|
+
// readers see 10, both write 15, and five units vanish.
|
|
50
|
+
expect(
|
|
51
|
+
onHand("p1", [
|
|
52
|
+
move({ id: "start", delta: 10 }),
|
|
53
|
+
move({ id: "a", delta: 5 }),
|
|
54
|
+
move({ id: "b", delta: 5 }),
|
|
55
|
+
]),
|
|
56
|
+
).toBe(20);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("ignores other products", () => {
|
|
60
|
+
expect(onHand("p1", [move({ productId: "p2", delta: 999 })])).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("no movements is zero", () => {
|
|
64
|
+
expect(onHand("p1", [])).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a corrupt delta doesn't poison the total", () => {
|
|
68
|
+
expect(
|
|
69
|
+
onHand("p1", [move({ delta: 5 }), move({ id: "x", delta: NaN as unknown as number })]),
|
|
70
|
+
).toBe(5);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("onHandByProduct", () => {
|
|
75
|
+
test("matches the per-product version for every product", () => {
|
|
76
|
+
const movements = [
|
|
77
|
+
move({ id: "a", productId: "p1", delta: 10 }),
|
|
78
|
+
move({ id: "b", productId: "p1", delta: -3 }),
|
|
79
|
+
move({ id: "c", productId: "p2", delta: 4 }),
|
|
80
|
+
];
|
|
81
|
+
const map = onHandByProduct(movements);
|
|
82
|
+
expect(map.get("p1")).toBe(onHand("p1", movements));
|
|
83
|
+
expect(map.get("p2")).toBe(onHand("p2", movements));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("an empty ledger is an empty map, not zeroes for phantom products", () => {
|
|
87
|
+
expect(onHandByProduct([]).size).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("stockState", () => {
|
|
92
|
+
test("zero or less is out, whatever the reorder point", () => {
|
|
93
|
+
expect(stockState(0, 5)).toBe("out");
|
|
94
|
+
expect(stockState(0, null)).toBe("out");
|
|
95
|
+
expect(stockState(-2, 5)).toBe("out");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("at or below the reorder point is low", () => {
|
|
99
|
+
expect(stockState(5, 5)).toBe("low");
|
|
100
|
+
expect(stockState(4, 5)).toBe("low");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("above it is fine", () => {
|
|
104
|
+
expect(stockState(6, 5)).toBe("ok");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("no reorder point means only zero is notable", () => {
|
|
108
|
+
expect(stockState(1, 0)).toBe("ok");
|
|
109
|
+
expect(stockState(1, null)).toBe("ok");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("reasonAllows", () => {
|
|
114
|
+
test("a sale can only remove stock", () => {
|
|
115
|
+
// A "Sold" that ADDS is a mis-click; letting it through makes the ledger
|
|
116
|
+
// untrustworthy, which is the only thing the ledger has going for it.
|
|
117
|
+
expect(reasonAllows("sold", -3)).toBe(true);
|
|
118
|
+
expect(reasonAllows("sold", 3)).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a receipt can only add", () => {
|
|
122
|
+
expect(reasonAllows("received", 5)).toBe(true);
|
|
123
|
+
expect(reasonAllows("received", -5)).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a stock count goes either way", () => {
|
|
127
|
+
expect(reasonAllows("count", 4)).toBe(true);
|
|
128
|
+
expect(reasonAllows("count", -4)).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("zero and unknown reasons are refused", () => {
|
|
132
|
+
expect(reasonAllows("received", 0)).toBe(false);
|
|
133
|
+
expect(reasonAllows("nonsense", 5)).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("every reason declares a direction", () => {
|
|
137
|
+
expect(REASONS.every((r) => ["in", "out", "either"].includes(r.direction))).toBe(true);
|
|
138
|
+
expect(isValidReason("damaged")).toBe(true);
|
|
139
|
+
expect(isValidReason("Damaged")).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("summarize", () => {
|
|
144
|
+
const products = [
|
|
145
|
+
product({ id: "ok", sku: "A", reorderPoint: 5, unitCostCents: 1000 }),
|
|
146
|
+
product({ id: "low", sku: "B", reorderPoint: 5, unitCostCents: 500 }),
|
|
147
|
+
product({ id: "out", sku: "C", reorderPoint: 5, unitCostCents: 200 }),
|
|
148
|
+
product({ id: "gone", sku: "D", archived: true, unitCostCents: 9999 }),
|
|
149
|
+
];
|
|
150
|
+
const movements = [
|
|
151
|
+
move({ id: "1", productId: "ok", delta: 20 }),
|
|
152
|
+
move({ id: "2", productId: "low", delta: 3 }),
|
|
153
|
+
move({ id: "3", productId: "gone", delta: 50 }),
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
test("counts active SKUs and units", () => {
|
|
157
|
+
const s = summarize(products, movements);
|
|
158
|
+
expect(s.skuCount).toBe(3);
|
|
159
|
+
expect(s.unitCount).toBe(23);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("values stock at COST", () => {
|
|
163
|
+
// 20 x $10.00 + 3 x $5.00
|
|
164
|
+
expect(summarize(products, movements).valuationCents).toBe(21_500);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("archived lines are excluded entirely", () => {
|
|
168
|
+
// Their history stays for auditing, but they aren't stock you hold.
|
|
169
|
+
const s = summarize(products, movements);
|
|
170
|
+
expect(s.unitCount).not.toBe(73);
|
|
171
|
+
expect(s.valuationCents).toBeLessThan(500_000);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("flags low and out separately", () => {
|
|
175
|
+
const s = summarize(products, movements);
|
|
176
|
+
expect(s.lowCount).toBe(1);
|
|
177
|
+
expect(s.outCount).toBe(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("negative stock doesn't credit the valuation", () => {
|
|
181
|
+
// It's a data error to surface, not value to subtract.
|
|
182
|
+
const s = summarize(
|
|
183
|
+
[product({ id: "bad", unitCostCents: 1000, reorderPoint: 0 })],
|
|
184
|
+
[move({ productId: "bad", delta: -4 })],
|
|
185
|
+
);
|
|
186
|
+
expect(s.valuationCents).toBe(0);
|
|
187
|
+
expect(s.unitCount).toBe(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("an empty shelf is zeroes", () => {
|
|
191
|
+
expect(summarize([], [])).toEqual({
|
|
192
|
+
skuCount: 0,
|
|
193
|
+
unitCount: 0,
|
|
194
|
+
valuationCents: 0,
|
|
195
|
+
lowCount: 0,
|
|
196
|
+
outCount: 0,
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("needsReorder", () => {
|
|
202
|
+
test("lists low and out, most urgent first", () => {
|
|
203
|
+
const products = [
|
|
204
|
+
product({ id: "ok", reorderPoint: 2 }),
|
|
205
|
+
product({ id: "low", reorderPoint: 5 }),
|
|
206
|
+
product({ id: "out", reorderPoint: 5 }),
|
|
207
|
+
];
|
|
208
|
+
const movements = [
|
|
209
|
+
move({ id: "1", productId: "ok", delta: 20 }),
|
|
210
|
+
move({ id: "2", productId: "low", delta: 3 }),
|
|
211
|
+
];
|
|
212
|
+
expect(needsReorder(products, movements).map((r) => r.product.id)).toEqual([
|
|
213
|
+
"out",
|
|
214
|
+
"low",
|
|
215
|
+
]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("excludes archived products", () => {
|
|
219
|
+
expect(
|
|
220
|
+
needsReorder([product({ id: "gone", archived: true, reorderPoint: 5 })], []),
|
|
221
|
+
).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("formatting", () => {
|
|
226
|
+
test("money is exact", () => {
|
|
227
|
+
expect(money(21_500)).toBe("$215.00");
|
|
228
|
+
expect(money(5)).toBe("$0.05");
|
|
229
|
+
expect(money(null)).toBe("$0.00");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("the sign is always shown on a delta", () => {
|
|
233
|
+
expect(signedQuantity(5)).toBe("+5");
|
|
234
|
+
expect(signedQuantity(-3)).toBe("−3");
|
|
235
|
+
expect(signedQuantity(0)).toBe("0");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("parsing", () => {
|
|
240
|
+
test("amounts become cents", () => {
|
|
241
|
+
expect(parseAmount("9.40")).toBe(940);
|
|
242
|
+
expect(parseAmount("$1,234")).toBe(123_400);
|
|
243
|
+
expect(parseAmount("abc")).toBeNull();
|
|
244
|
+
expect(parseAmount("")).toBeNull();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("counts are whole units only", () => {
|
|
248
|
+
// Half a physical thing is a unit-of-measure mistake, not a quantity.
|
|
249
|
+
expect(parseCount("12")).toBe(12);
|
|
250
|
+
expect(parseCount("-3")).toBe(-3);
|
|
251
|
+
expect(parseCount("1.5")).toBeNull();
|
|
252
|
+
expect(parseCount("abc")).toBeNull();
|
|
253
|
+
expect(parseCount("")).toBeNull();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"jsx": "react",
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"lib": [
|
|
11
|
+
"ES2022",
|
|
12
|
+
"DOM"
|
|
13
|
+
],
|
|
14
|
+
"types": [
|
|
15
|
+
"react",
|
|
16
|
+
"react-dom",
|
|
17
|
+
"node"
|
|
18
|
+
],
|
|
19
|
+
"baseUrl": ".",
|
|
20
|
+
"paths": {
|
|
21
|
+
"@/*": [
|
|
22
|
+
"./*"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"include": [
|
|
27
|
+
"app.ts",
|
|
28
|
+
"app/**/*",
|
|
29
|
+
"components/**/*",
|
|
30
|
+
"lib/**/*",
|
|
31
|
+
"functions/**/*"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# AGENTS.md: working in a Pylon project
|
|
2
|
+
|
|
3
|
+
Pylon serves the API, auth, sync, WebSocket, SSE, and native React 19 SSR from one Rust process on one port. Treat this app as production infrastructure: it supports real auth, SQLite or Postgres, row-level policies, jobs, search, and one-command deploys. Declare entities, policies, and server functions in TypeScript; the binary handles the runtime. Read the full API reference at https://docs.pylonsync.com/llms-full.txt before guessing an API name.
|
|
4
|
+
|
|
5
|
+
## Directory conventions
|
|
6
|
+
|
|
7
|
+
**Unified SSR app:**
|
|
8
|
+
- `app.ts`: data model + manifest (`entity()` + `field.*`, queries/actions/policies, `routes: await discoverAppRoutes()`). Ends with `console.log(JSON.stringify(manifest))`.
|
|
9
|
+
- `app/`: file-based SSR routes. `app/page.tsx` → `/`, `app/about/page.tsx` → `/about`, `app/blog/[slug]/page.tsx` → `/blog/:slug`. `app/layout.tsx` is the shell; `app/error.tsx` / `app/not-found.tsx` are boundaries.
|
|
10
|
+
- `app/globals.css`: Tailwind v4 entrypoint (auto-compiled and injected).
|
|
11
|
+
- `functions/`: server functions, one per file, `default`-exported.
|
|
12
|
+
- `.pylon/`: local dev state (SQLite, jobs, sessions, uploads). Created by `pylon dev`. Do not commit.
|
|
13
|
+
|
|
14
|
+
**Monorepo app:** backend is `apps/api/` (entry `apps/api/schema.ts`, handlers in `apps/api/functions/`); frontend in `apps/web/`. `pylon.manifest.json` / `pylon.client.ts` are generated; do not hand-edit.
|
|
15
|
+
|
|
16
|
+
## The core authoring loop
|
|
17
|
+
|
|
18
|
+
1. **Define an entity:** `entity("Thing", { name: field.string(), done: field.boolean().default(false) })`. Modifiers: `.optional()`, `.unique()`, `.readonly()` (settable on insert, rejected on client update; use for `authorId`/`orgId`), `.serverOnly()` (never in HTTP responses), `.encrypted()` (AEAD at rest, needs `PYLON_ENCRYPTION_KEY`), `.crdt("text")` (collaborative).
|
|
19
|
+
2. **Write a policy:** `policy({ entity: "Thing", allowRead, allowInsert, allowUpdate, allowDelete })` with CEL-like expressions over `auth.*` / `data.*` (e.g. `"auth.userId == data.authorId"`). Omitted actions deny by default. `pylon lint` flags wide-open development policies such as `allow*: "true"`; tighten them before shipping.
|
|
20
|
+
3. **Author a function** in `functions/<name>.ts`: `query` (read-only), `mutation` (transactional read+write), or `action` (external I/O, no direct `ctx.db`). Import `{ query, mutation, action, v }` from `@pylonsync/functions`. `auth` defaults to `"user"` (secure-by-default); set `"public"` explicitly for unauthenticated access. Use `ctx.db.*`, `ctx.auth.userId`, `ctx.error(code, msg)`.
|
|
21
|
+
4. **Read it on the client:** `db.useQuery("Thing")` (live, re-renders on any write) or `db.useQueryOne("Thing", id)`. Call functions with `db.fn(name, args)` / `callFn`. On SSR pages, read via `use(serverData.list("Thing"))` inside `<Suspense>`.
|
|
22
|
+
|
|
23
|
+
## Key gotchas
|
|
24
|
+
|
|
25
|
+
- **Policies deny by default; server functions BYPASS them.** Direct client CRUD (`/api/entities/*`) and sync are policy-checked. Functions run with full DB access; enforce trust with `ctx.auth` checks inside the handler, not policies.
|
|
26
|
+
- **Type page props from the SDK, don't hand-roll them.** `import type { PageProps, Metadata } from "@pylonsync/react"`. Every page/layout gets `{ url, params, searchParams, auth, response, serverData }`; `PageProps<{ slug: string }>` types a `[slug]` route's params. Request headers/cookies are intentionally NOT on `PageProps`; they're server-only and stripped from hydration, so reading them in the render would mismatch.
|
|
27
|
+
- **Anonymous output caching is opt-in and conditional.** `export const revalidate = 60` makes a page CDN-cacheable (`public, s-maxage=60`) only when the render is auth-independent: it must not read `props.auth`, set a cookie, or run with strict per-caller policies (`PYLON_STRICT_FN_POLICIES`). `export const dynamic = "force-static"` caches until the next deploy; `"force-dynamic"` never caches. When any condition fails, the page is `no-cache`. Eligible renders also use the origin disk cache at `.pylon/.cache/ssr`: a cookie-less GET without a query string is served from disk for the TTL and rerendered when stale. The cache is namespaced per deploy, cleared by each build, disabled in `pylon dev`, and invalidated by the `revalidate` TTL or the next deploy.
|
|
28
|
+
- **No-JS forms use `route.ts` and `<Form>`.** Add `app/.../route.ts` exporting `export const POST: RouteHandler = async ({ form, db, response, auth }) => { await db.insert("X", {...}); response.redirect("/x?ok=1"); }` (303 POST-redirect-GET by default). Render `<Form action="/x">` from `@pylonsync/react` with plain `<input name=...>`. It uses native POST → handler → redirect without JavaScript and no-reload enhancement with JavaScript. The handler's `db` is read+write under the mutation trust model, so gate it on `auth`. CSRF protection is automatic through the Origin gate and SameSite=Lax. Multipart uploads are not supported yet; use URL-encoded forms and `/api/files`.
|
|
29
|
+
- **`loading.tsx` streams a skeleton while the page's data resolves.** Drop `app/.../loading.tsx` (default export, page props) and the nearest one becomes a route-level Suspense fallback: Pylon flushes the shell + skeleton immediately, then reveals the real page when its top-level `use(serverData…)` resolves (no blank page). It only shows when the PAGE suspends; a page that wraps its own `<Suspense>` around a child (like `/dashboard` in this template) handles that itself. The skeleton is SERVER-ONLY: don't read `serverData` in it. A page with no `loading.tsx` is buffered (unchanged).
|
|
30
|
+
- **`export const streaming = true` streams a page's inner `<Suspense>` boundaries.** Without it or a `loading.tsx`, the page is buffered until all suspended children resolve. With it, the shell and fallbacks flush immediately, then each boundary streams its content. Streaming commits the HTTP head before suspended subtrees finish, so the page is never CDN- or disk-cacheable; do not combine it with `export const revalidate`. Calls to `response.setStatus`, `setCookie`, `redirect`, or `notFound` only take effect during the synchronous shell render. A call from a suspended subtree is dropped and logged. An error from a deep `<Suspense>` child resolves through the nearest `error.tsx` at HTTP 200 rather than 5xx. Type the config with `import type { RouteSegmentConfig } from "@pylonsync/react"`.
|
|
31
|
+
- **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
|
|
32
|
+
- **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `url` / `searchParams` page props.
|
|
33
|
+
- **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
|
|
34
|
+
- **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
|
|
35
|
+
- **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
|
|
36
|
+
- **`ctx.llm` and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
|
|
37
|
+
- **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
|
|
38
|
+
- **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
|
|
39
|
+
|
|
40
|
+
## Testing
|
|
41
|
+
|
|
42
|
+
`pylon test` discovers every `*.test.ts` / `*.test.tsx` file under `tests/` (or `functions/`) and runs it with **Bun's test runner** (`import { test, expect } from "bun:test"`). Run the suite with `pylon test` (or `npm test`); filter with `pylon test <substring>`. This template ships `bunfig.toml` + `tests/setup.ts` (registers happy-dom) so component tests render out of the box, plus starter tests under `tests/`; replace them with your own.
|
|
43
|
+
|
|
44
|
+
**Tier 1: pure logic (start here).** Keep access and plan gating, pricing, credit math, validation, and formatting in pure functions under `lib/`, and test them exhaustively. These tests need no server and run instantly. Keep `query`, `mutation`, and `action` handlers as thin wrappers so their decision logic remains testable without a running app.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { expect, test } from "bun:test";
|
|
48
|
+
import { productBySlug } from "../lib/site.config";
|
|
49
|
+
|
|
50
|
+
test("unknown slug → undefined", () => {
|
|
51
|
+
expect(productBySlug("nope")).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Tier 2: React components.** `@testing-library/react` and happy-dom are already wired through `tests/setup.ts`. Render and assert. The template uses the classic JSX transform, so add `import React from "react"` in `.tsx` tests. For a component that reads Pylon data hooks, mock the boundary, then dynamically import the component so the mock is in place first:
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
import { test, expect, mock } from "bun:test";
|
|
59
|
+
import React from "react";
|
|
60
|
+
import { render, screen } from "@testing-library/react";
|
|
61
|
+
|
|
62
|
+
mock.module("@pylonsync/react", () => ({
|
|
63
|
+
db: { useQuery: () => ({ data: [{ id: "1", name: "Acme" }], loading: false }) },
|
|
64
|
+
}));
|
|
65
|
+
const { OrgList } = await import("../app/orgs/org-list"); // your component
|
|
66
|
+
|
|
67
|
+
test("renders orgs from the query", () => {
|
|
68
|
+
render(<OrgList />);
|
|
69
|
+
expect(screen.getByText("Acme")).toBeDefined();
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Tier 3: functions over HTTP.** Use this tier when pure logic tests cannot cover the behavior. A handler's policies, `ctx.db` calls, and auth run in the app. Start `pylon dev` in another terminal and call the API. `resetDb()` from `@pylonsync/functions` clears the in-memory database between cases; it does nothing when the server is down and refuses to run in production.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { afterEach, expect, test } from "bun:test";
|
|
77
|
+
import { resetDb } from "@pylonsync/functions";
|
|
78
|
+
|
|
79
|
+
const BASE = "http://localhost:4321";
|
|
80
|
+
afterEach(() => resetDb(BASE)); // or installTestIsolation(BASE) once at top-of-file
|
|
81
|
+
|
|
82
|
+
test("createThing then read it back", async () => {
|
|
83
|
+
const t = await fetch(`${BASE}/api/fn/createThing`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "Content-Type": "application/json" },
|
|
86
|
+
body: JSON.stringify({ name: "hello" }),
|
|
87
|
+
}).then((r) => r.json());
|
|
88
|
+
const rows = await fetch(`${BASE}/api/entities/Thing`).then((r) => r.json());
|
|
89
|
+
expect(rows.some((r: { id: string }) => r.id === t.id)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`pylon test:security` is a separate adversarial probe; it hits a running app and reports auth/policy holes (run `pylon dev`, then `pylon test:security`).
|
|
94
|
+
|
|
95
|
+
## Use the CLI
|
|
96
|
+
|
|
97
|
+
| Need | Command |
|
|
98
|
+
|---|---|
|
|
99
|
+
| Run the app (SSR + API, hot reload, one port `:4321`) | `pylon dev` (or `npm run dev`) |
|
|
100
|
+
| Regenerate manifest + typed client | `pylon codegen` (Swift client: `pylon codegen client --target swift`) |
|
|
101
|
+
| Validate / diff / push schema | `pylon schema check` \| `diff` \| `push` |
|
|
102
|
+
| Migrations | `pylon migrate create <name>` \| `plan` \| `apply` |
|
|
103
|
+
| Lint policies (PYL001–PYL004) | `pylon lint --strict` |
|
|
104
|
+
| Tests | `pylon test` |
|
|
105
|
+
| Adversarial security probe | `pylon test:security` |
|
|
106
|
+
| Inspect cloud request logs (agent-safe) | `pylon logs --json --limit 50` |
|
|
107
|
+
| Inspect data / entities | `pylon data entities` \| `pylon data list <Entity>` |
|
|
108
|
+
| Call a function | `pylon fn <name> key=value` |
|
|
109
|
+
| Health snapshot | `pylon status` |
|
|
110
|
+
| Build for prod | `pylon build` |
|
|
111
|
+
| Deploy (Pylon Cloud by default) | `pylon deploy` |
|
|
112
|
+
| Look up an error code | `pylon explain <CODE>` |
|
|
113
|
+
|
|
114
|
+
`--json` works on every command for machine-readable output. Prefer one-shot/agent-safe flags (`pylon logs --limit N`, not a blocking `--follow`).
|
|
115
|
+
|
|
116
|
+
For full signatures, env vars, the complete CLI, and SSR/client/server-primitive details: **https://docs.pylonsync.com/llms-full.txt**.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# __APP_NAME__
|
|
2
|
+
|
|
3
|
+
Invoicing for a small business — clients, line items, and payments. One
|
|
4
|
+
[Pylon](https://pylonsync.com) app: SSR frontend, API, auth, and realtime sync
|
|
5
|
+
from one process on one port.
|
|
6
|
+
|
|
7
|
+
## Develop
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
__RUN_DEV__
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Open http://localhost:4321 and create an account. The first sign-in seeds a
|
|
14
|
+
realistic book — including one invoice already overdue and one part-paid — so
|
|
15
|
+
those states are visible rather than theoretical. Delete
|
|
16
|
+
`functions/seedWorkspace.ts` and `lib/seed.ts` once you're billing real clients.
|
|
17
|
+
|
|
18
|
+
## What's interesting here
|
|
19
|
+
|
|
20
|
+
**Money is integer cents, everywhere.** Floating-point dollars is how an invoice
|
|
21
|
+
ends up a penny off after tax, and a penny on a bill is a support ticket.
|
|
22
|
+
Quantities are thousandths for the same reason. Conversion happens only at the
|
|
23
|
+
edges — `parseAmount` on input, `money` on output.
|
|
24
|
+
|
|
25
|
+
**Totals and ageing are derived, never stored.** An invoice becomes overdue by
|
|
26
|
+
the clock passing, so nothing has to run at midnight to flip a column, and the
|
|
27
|
+
figure on screen always reconciles with the lines you can see. Tax is applied to
|
|
28
|
+
the rounded subtotal, which is what a customer can reproduce from the printed
|
|
29
|
+
invoice.
|
|
30
|
+
|
|
31
|
+
**The balance is computed server-side.** `recordPayment` recomputes from the
|
|
32
|
+
line items and existing payments before accepting anything — trusting a
|
|
33
|
+
client-sent balance would let a stale tab overpay, or mark an invoice settled
|
|
34
|
+
that isn't. Overpayment is rejected rather than silently producing a credit.
|
|
35
|
+
|
|
36
|
+
**It's live.** Recording a payment flips the balance and status on every open
|
|
37
|
+
tab, so two people chasing the same overdue invoice stop the moment one of them
|
|
38
|
+
marks it paid.
|
|
39
|
+
|
|
40
|
+
**Numbers come from the series, not a counter.** `nextNumber` derives the next
|
|
41
|
+
one from the existing rows: a counter in a synced replica is a race, and gaps in
|
|
42
|
+
an invoice series get asked about by accountants. `number` is unique in the
|
|
43
|
+
schema, so a genuine collision fails loudly.
|
|
44
|
+
|
|
45
|
+
## Layout
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
app.ts entities + policies
|
|
49
|
+
app/
|
|
50
|
+
page.tsx "/" — the invoice list
|
|
51
|
+
invoices-view.tsx list container
|
|
52
|
+
workspace.tsx app shell — the ONLY place that touches `db`
|
|
53
|
+
invoices/[id]/ invoice detail, line items, payments
|
|
54
|
+
clients/ client list
|
|
55
|
+
login/ email/password auth
|
|
56
|
+
components/ presentational — props in, callbacks out
|
|
57
|
+
ui/ shadcn primitives
|
|
58
|
+
lib/billing.ts money, totals, ageing, numbering — pure
|
|
59
|
+
lib/format.ts time + initials — pure
|
|
60
|
+
functions/ server functions
|
|
61
|
+
tests/ pure logic + component tests
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The data boundary lives in exactly one module (`app/workspace.tsx`). Everything
|
|
65
|
+
under `components/` takes data as props and reports changes through callbacks,
|
|
66
|
+
which is why the component tests render them with fixtures and mock nothing.
|
|
67
|
+
|
|
68
|
+
## A deliberate limitation
|
|
69
|
+
|
|
70
|
+
**A sent invoice can't have its lines edited.** It's a document someone is
|
|
71
|
+
paying against, and silently mutating it desyncs your copy from the one in their
|
|
72
|
+
inbox. Correct a sent invoice by voiding it and issuing another — the way paper
|
|
73
|
+
accounting has always worked, and the way an auditor expects.
|
|
74
|
+
|
|
75
|
+
## Access
|
|
76
|
+
|
|
77
|
+
Every entity is readable and writable by any **signed-in** user, and by nobody
|
|
78
|
+
else. There is **no client portal**, and no policy allows an anonymous read.
|
|
79
|
+
Adding one means a separate, narrower policy scoped to that client's own
|
|
80
|
+
invoices, not loosening these.
|
|
81
|
+
|
|
82
|
+
## Grow it
|
|
83
|
+
|
|
84
|
+
- **Change the tax model:** `totals` in `lib/billing.ts`. Per-line tax is a
|
|
85
|
+
small change there and nowhere else.
|
|
86
|
+
- **Add a status:** `STATUSES` in the same file. Note that `overdue` is
|
|
87
|
+
deliberately derived and not settable.
|
|
88
|
+
- **Email an invoice:** an action that renders the lines and sends through
|
|
89
|
+
`ctx.email` — the totals helper already gives you every figure.
|
|
90
|
+
|
|
91
|
+
## Deploy
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pylon lint --strict
|
|
95
|
+
pylon test
|
|
96
|
+
pylon deploy
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Docs: https://docs.pylonsync.com
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { db } from "@pylonsync/react";
|
|
5
|
+
import { Plus, Users } from "lucide-react";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { PageHeader } from "@/components/page-header";
|
|
8
|
+
import { DataTable, type ColumnDef } from "@/components/data-table";
|
|
9
|
+
import { EmptyState } from "@/components/empty-state";
|
|
10
|
+
import { RecordDialog } from "@/components/record-dialog";
|
|
11
|
+
import { Avatar } from "@/components/avatar";
|
|
12
|
+
import { money, totals } from "@/lib/billing";
|
|
13
|
+
import { Workspace, type ClientRow } from "../workspace";
|
|
14
|
+
|
|
15
|
+
export function ClientsView({ email }: { email: string }) {
|
|
16
|
+
const [open, setOpen] = useState(false);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Workspace email={email} pathname="/clients">
|
|
20
|
+
{(data) => {
|
|
21
|
+
// Outstanding per client, derived rather than stored — one less thing
|
|
22
|
+
// to keep in sync when a payment lands.
|
|
23
|
+
const outstanding = (clientId: string) =>
|
|
24
|
+
data.invoices
|
|
25
|
+
.filter((invoice) => invoice.clientId === clientId && invoice.status === "sent")
|
|
26
|
+
.reduce(
|
|
27
|
+
(sum, invoice) =>
|
|
28
|
+
sum + totals(invoice, data.items, data.payments).balanceCents,
|
|
29
|
+
0,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const columns: ColumnDef<ClientRow>[] = [
|
|
33
|
+
{
|
|
34
|
+
key: "name",
|
|
35
|
+
header: "Client",
|
|
36
|
+
cell: (row) => (
|
|
37
|
+
<span className="flex items-center gap-2">
|
|
38
|
+
<Avatar name={row.name} size="sm" />
|
|
39
|
+
<span className="truncate font-medium">{row.name}</span>
|
|
40
|
+
</span>
|
|
41
|
+
),
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
key: "email",
|
|
45
|
+
header: "Email",
|
|
46
|
+
cell: (row) =>
|
|
47
|
+
row.email ? (
|
|
48
|
+
<a
|
|
49
|
+
href={`mailto:${row.email}`}
|
|
50
|
+
onClick={(event) => event.stopPropagation()}
|
|
51
|
+
className="text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
|
52
|
+
>
|
|
53
|
+
{row.email}
|
|
54
|
+
</a>
|
|
55
|
+
) : (
|
|
56
|
+
<span className="text-muted-foreground">—</span>
|
|
57
|
+
),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
key: "invoices",
|
|
61
|
+
header: "Invoices",
|
|
62
|
+
numeric: true,
|
|
63
|
+
cell: (row) =>
|
|
64
|
+
data.invoices.filter((invoice) => invoice.clientId === row.id).length,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
key: "outstanding",
|
|
68
|
+
header: "Outstanding",
|
|
69
|
+
numeric: true,
|
|
70
|
+
cell: (row) => {
|
|
71
|
+
const value = outstanding(row.id);
|
|
72
|
+
return (
|
|
73
|
+
<span className={value > 0 ? "" : "text-muted-foreground"}>
|
|
74
|
+
{money(value)}
|
|
75
|
+
</span>
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const rows = [...data.clients].sort((a, b) => a.name.localeCompare(b.name));
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<>
|
|
85
|
+
<PageHeader title="Clients" count={rows.length}>
|
|
86
|
+
<Button size="sm" onClick={() => setOpen(true)}>
|
|
87
|
+
<Plus />
|
|
88
|
+
New client
|
|
89
|
+
</Button>
|
|
90
|
+
</PageHeader>
|
|
91
|
+
|
|
92
|
+
<DataTable
|
|
93
|
+
rows={rows}
|
|
94
|
+
columns={columns}
|
|
95
|
+
empty={
|
|
96
|
+
<EmptyState
|
|
97
|
+
icon={<Users />}
|
|
98
|
+
title="No clients yet"
|
|
99
|
+
description="Who you bill. Their address appears on the invoice, so add it once here."
|
|
100
|
+
action={
|
|
101
|
+
<Button size="sm" onClick={() => setOpen(true)}>
|
|
102
|
+
<Plus />
|
|
103
|
+
New client
|
|
104
|
+
</Button>
|
|
105
|
+
}
|
|
106
|
+
/>
|
|
107
|
+
}
|
|
108
|
+
/>
|
|
109
|
+
|
|
110
|
+
<RecordDialog
|
|
111
|
+
open={open}
|
|
112
|
+
title="New client"
|
|
113
|
+
submitLabel="Create client"
|
|
114
|
+
onOpenChange={setOpen}
|
|
115
|
+
fields={[
|
|
116
|
+
{ name: "name", label: "Name", required: true, placeholder: "Northwind Logistics" },
|
|
117
|
+
{ name: "email", label: "Billing email", type: "email", placeholder: "ap@northwind.co" },
|
|
118
|
+
{ name: "address", label: "Address", placeholder: "412 Dock Road, Rotterdam" },
|
|
119
|
+
{ name: "taxId", label: "Tax ID", placeholder: "VAT / company number" },
|
|
120
|
+
]}
|
|
121
|
+
onCreate={async (values) => {
|
|
122
|
+
await db.insert("Client", values);
|
|
123
|
+
}}
|
|
124
|
+
/>
|
|
125
|
+
</>
|
|
126
|
+
);
|
|
127
|
+
}}
|
|
128
|
+
</Workspace>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React, { use } from "react";
|
|
2
|
+
import type { Metadata, PageProps } from "@pylonsync/react";
|
|
3
|
+
import { ClientsView } from "./clients-view";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: "Clients",
|
|
7
|
+
robots: "noindex",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default function ClientsPage({ auth, response, serverData }: PageProps) {
|
|
11
|
+
if (!auth.user_id || auth.user_id.startsWith("guest_")) {
|
|
12
|
+
response.redirect("/login");
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
|
|
16
|
+
return <ClientsView email={me?.email ?? ""} />;
|
|
17
|
+
}
|