@unifiedcommerce/plugin-pos 0.0.1
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/dist/hooks/checkout-pos.d.ts +29 -0
- package/dist/hooks/checkout-pos.d.ts.map +1 -0
- package/dist/hooks/checkout-pos.js +69 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +109 -0
- package/dist/payment-adapter.d.ts +33 -0
- package/dist/payment-adapter.d.ts.map +1 -0
- package/dist/payment-adapter.js +37 -0
- package/dist/routes/lookup.d.ts +9 -0
- package/dist/routes/lookup.d.ts.map +1 -0
- package/dist/routes/lookup.js +37 -0
- package/dist/routes/payments.d.ts +10 -0
- package/dist/routes/payments.d.ts.map +1 -0
- package/dist/routes/payments.js +62 -0
- package/dist/routes/receipts.d.ts +9 -0
- package/dist/routes/receipts.d.ts.map +1 -0
- package/dist/routes/receipts.js +28 -0
- package/dist/routes/returns.d.ts +21 -0
- package/dist/routes/returns.d.ts.map +1 -0
- package/dist/routes/returns.js +83 -0
- package/dist/routes/shifts.d.ts +9 -0
- package/dist/routes/shifts.d.ts.map +1 -0
- package/dist/routes/shifts.js +91 -0
- package/dist/routes/terminals.d.ts +9 -0
- package/dist/routes/terminals.d.ts.map +1 -0
- package/dist/routes/terminals.js +55 -0
- package/dist/routes/transactions.d.ts +19 -0
- package/dist/routes/transactions.d.ts.map +1 -0
- package/dist/routes/transactions.js +175 -0
- package/dist/schema.d.ts +1337 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +123 -0
- package/dist/services/lookup-service.d.ts +38 -0
- package/dist/services/lookup-service.d.ts.map +1 -0
- package/dist/services/lookup-service.js +104 -0
- package/dist/services/payment-service.d.ts +40 -0
- package/dist/services/payment-service.d.ts.map +1 -0
- package/dist/services/payment-service.js +99 -0
- package/dist/services/receipt-service.d.ts +45 -0
- package/dist/services/receipt-service.d.ts.map +1 -0
- package/dist/services/receipt-service.js +119 -0
- package/dist/services/return-service.d.ts +27 -0
- package/dist/services/return-service.d.ts.map +1 -0
- package/dist/services/return-service.js +51 -0
- package/dist/services/shift-service.d.ts +36 -0
- package/dist/services/shift-service.d.ts.map +1 -0
- package/dist/services/shift-service.js +198 -0
- package/dist/services/terminal-service.d.ts +21 -0
- package/dist/services/terminal-service.d.ts.map +1 -0
- package/dist/services/terminal-service.js +59 -0
- package/dist/services/transaction-service.d.ts +30 -0
- package/dist/services/transaction-service.d.ts.map +1 -0
- package/dist/services/transaction-service.js +202 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/package.json +40 -0
- package/src/hooks/checkout-pos.ts +93 -0
- package/src/index.ts +131 -0
- package/src/payment-adapter.ts +53 -0
- package/src/routes/lookup.ts +44 -0
- package/src/routes/payments.ts +82 -0
- package/src/routes/receipts.ts +35 -0
- package/src/routes/returns.ts +116 -0
- package/src/routes/shifts.ts +100 -0
- package/src/routes/terminals.ts +62 -0
- package/src/routes/transactions.ts +192 -0
- package/src/schema.ts +136 -0
- package/src/services/lookup-service.ts +136 -0
- package/src/services/payment-service.ts +133 -0
- package/src/services/receipt-service.ts +169 -0
- package/src/services/return-service.ts +65 -0
- package/src/services/shift-service.ts +260 -0
- package/src/services/terminal-service.ts +76 -0
- package/src/services/transaction-service.ts +248 -0
- package/src/types.ts +49 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { router } from "@unifiedcommerce/core";
|
|
2
|
+
import { z } from "@hono/zod-openapi";
|
|
3
|
+
import type { ShiftService } from "../services/shift-service";
|
|
4
|
+
import type { PluginRouteRegistration } from "@unifiedcommerce/core";
|
|
5
|
+
|
|
6
|
+
export function buildShiftRoutes(
|
|
7
|
+
service: ShiftService,
|
|
8
|
+
ctx: { services?: Record<string, unknown>; database?: { db: unknown } },
|
|
9
|
+
): PluginRouteRegistration[] {
|
|
10
|
+
const r = router("POS Shifts", "/pos/shifts", ctx);
|
|
11
|
+
|
|
12
|
+
r.post("/open")
|
|
13
|
+
.summary("Open shift")
|
|
14
|
+
.permission("pos:operate")
|
|
15
|
+
.input(z.object({
|
|
16
|
+
terminalId: z.string().uuid(),
|
|
17
|
+
openingFloat: z.number().int().min(0),
|
|
18
|
+
}))
|
|
19
|
+
.handler(async ({ input, actor, orgId }) => {
|
|
20
|
+
const body = input as { terminalId: string; openingFloat: number };
|
|
21
|
+
const result = await service.open(orgId, {
|
|
22
|
+
...body,
|
|
23
|
+
operatorId: actor!.userId,
|
|
24
|
+
});
|
|
25
|
+
if (!result.ok) throw new Error(result.error);
|
|
26
|
+
return result.value;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
r.post("/{id}/close")
|
|
30
|
+
.summary("Close shift")
|
|
31
|
+
.permission("pos:operate")
|
|
32
|
+
.input(z.object({
|
|
33
|
+
closingCount: z.number().int().min(0),
|
|
34
|
+
}))
|
|
35
|
+
.handler(async ({ params, input, orgId }) => {
|
|
36
|
+
const body = input as { closingCount: number };
|
|
37
|
+
const result = await service.close(orgId, params.id!, body);
|
|
38
|
+
if (!result.ok) throw new Error(result.error);
|
|
39
|
+
return result.value;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
r.get("/current")
|
|
43
|
+
.summary("Get current open shift")
|
|
44
|
+
.permission("pos:operate")
|
|
45
|
+
.handler(async ({ actor, orgId }) => {
|
|
46
|
+
const result = await service.getCurrent(orgId, actor!.userId);
|
|
47
|
+
if (!result.ok) throw new Error(result.error);
|
|
48
|
+
return result.value;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
r.get("/{id}")
|
|
52
|
+
.summary("Get shift details")
|
|
53
|
+
.permission("pos:operate")
|
|
54
|
+
.handler(async ({ params, orgId }) => {
|
|
55
|
+
const result = await service.getById(orgId, params.id!);
|
|
56
|
+
if (!result.ok) throw new Error(result.error);
|
|
57
|
+
return result.value;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
r.get("/{id}/report")
|
|
61
|
+
.summary("Z-report")
|
|
62
|
+
.permission("pos:admin")
|
|
63
|
+
.handler(async ({ params, orgId }) => {
|
|
64
|
+
const result = await service.getReport(orgId, params.id!);
|
|
65
|
+
if (!result.ok) throw new Error(result.error);
|
|
66
|
+
return result.value;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ─── Cash Events ───────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
r.post("/{id}/cash-events")
|
|
72
|
+
.summary("Record cash event")
|
|
73
|
+
.permission("pos:operate")
|
|
74
|
+
.input(z.object({
|
|
75
|
+
type: z.enum(["drop", "pickup", "paid_in", "paid_out"]),
|
|
76
|
+
amount: z.number().int().positive(),
|
|
77
|
+
reason: z.string().max(500).optional(),
|
|
78
|
+
}))
|
|
79
|
+
.handler(async ({ params, input, actor }) => {
|
|
80
|
+
const body = input as { type: "drop" | "pickup" | "paid_in" | "paid_out"; amount: number; reason?: string };
|
|
81
|
+
const result = await service.addCashEvent(params.id!, {
|
|
82
|
+
...body,
|
|
83
|
+
performedBy: actor!.userId,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!result.ok) throw new Error(result.error);
|
|
87
|
+
return result.value;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
r.get("/{id}/cash-events")
|
|
91
|
+
.summary("List cash events")
|
|
92
|
+
.permission("pos:operate")
|
|
93
|
+
.handler(async ({ params }) => {
|
|
94
|
+
const result = await service.listCashEvents(params.id!);
|
|
95
|
+
if (!result.ok) throw new Error(result.error);
|
|
96
|
+
return result.value;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return r.routes();
|
|
100
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { router } from "@unifiedcommerce/core";
|
|
2
|
+
import { z } from "@hono/zod-openapi";
|
|
3
|
+
import type { TerminalService } from "../services/terminal-service";
|
|
4
|
+
import type { PluginRouteRegistration } from "@unifiedcommerce/core";
|
|
5
|
+
|
|
6
|
+
export function buildTerminalRoutes(
|
|
7
|
+
service: TerminalService,
|
|
8
|
+
ctx: { services?: Record<string, unknown>; database?: { db: unknown } },
|
|
9
|
+
): PluginRouteRegistration[] {
|
|
10
|
+
const r = router("POS Terminals", "/pos/terminals", ctx);
|
|
11
|
+
|
|
12
|
+
r.post("/")
|
|
13
|
+
.summary("Register terminal")
|
|
14
|
+
.permission("pos:admin")
|
|
15
|
+
.input(z.object({
|
|
16
|
+
name: z.string().min(1).max(100),
|
|
17
|
+
code: z.string().min(1).max(20),
|
|
18
|
+
type: z.enum(["register", "tablet", "mobile", "kiosk"]).optional(),
|
|
19
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
20
|
+
}))
|
|
21
|
+
.handler(async ({ input, orgId }) => {
|
|
22
|
+
const body = input as { name: string; code: string; type?: "register" | "tablet" | "mobile" | "kiosk"; metadata?: Record<string, unknown> };
|
|
23
|
+
const result = await service.create(orgId, body);
|
|
24
|
+
if (!result.ok) throw new Error(result.error);
|
|
25
|
+
return result.value;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
r.get("/")
|
|
29
|
+
.summary("List terminals")
|
|
30
|
+
.permission("pos:admin")
|
|
31
|
+
.handler(async ({ orgId }) => {
|
|
32
|
+
const result = await service.list(orgId);
|
|
33
|
+
if (!result.ok) throw new Error(result.error);
|
|
34
|
+
return result.value;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
r.patch("/{id}")
|
|
38
|
+
.summary("Update terminal")
|
|
39
|
+
.permission("pos:admin")
|
|
40
|
+
.input(z.object({
|
|
41
|
+
name: z.string().min(1).max(100).optional(),
|
|
42
|
+
isActive: z.boolean().optional(),
|
|
43
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
44
|
+
}))
|
|
45
|
+
.handler(async ({ params, input, orgId }) => {
|
|
46
|
+
const body = input as { name?: string; isActive?: boolean; metadata?: Record<string, unknown> };
|
|
47
|
+
const result = await service.update(orgId, params.id!, body);
|
|
48
|
+
if (!result.ok) throw new Error(result.error);
|
|
49
|
+
return result.value;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
r.delete("/{id}")
|
|
53
|
+
.summary("Deactivate terminal")
|
|
54
|
+
.permission("pos:admin")
|
|
55
|
+
.handler(async ({ params, orgId }) => {
|
|
56
|
+
const result = await service.deactivate(orgId, params.id!);
|
|
57
|
+
if (!result.ok) throw new Error(result.error);
|
|
58
|
+
return result.value;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return r.routes();
|
|
62
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { router } from "@unifiedcommerce/core";
|
|
2
|
+
import { z } from "@hono/zod-openapi";
|
|
3
|
+
import type { TransactionService } from "../services/transaction-service";
|
|
4
|
+
import type { PluginRouteRegistration } from "@unifiedcommerce/core";
|
|
5
|
+
|
|
6
|
+
export function buildTransactionRoutes(
|
|
7
|
+
service: TransactionService,
|
|
8
|
+
cartService: { create: (input: { currency?: string; metadata?: Record<string, unknown> }, actor: unknown) => Promise<{ ok: boolean; value?: { id: string } }> },
|
|
9
|
+
ctx: { services?: Record<string, unknown>; database?: { db: unknown } },
|
|
10
|
+
): PluginRouteRegistration[] {
|
|
11
|
+
const r = router("POS Transactions", "/pos/transactions", ctx);
|
|
12
|
+
|
|
13
|
+
r.post("/")
|
|
14
|
+
.summary("Start transaction")
|
|
15
|
+
.permission("pos:operate")
|
|
16
|
+
.input(z.object({
|
|
17
|
+
shiftId: z.string().uuid(),
|
|
18
|
+
terminalId: z.string().uuid(),
|
|
19
|
+
type: z.enum(["sale", "return", "exchange"]).optional(),
|
|
20
|
+
customerId: z.string().uuid().optional(),
|
|
21
|
+
currency: z.string().min(3).max(3).optional(),
|
|
22
|
+
}))
|
|
23
|
+
.handler(async ({ input, actor, orgId }) => {
|
|
24
|
+
const body = input as { shiftId: string; terminalId: string; type?: "sale" | "return" | "exchange"; customerId?: string; currency?: string };
|
|
25
|
+
|
|
26
|
+
// Create a cart for this transaction
|
|
27
|
+
const cartResult = await cartService.create(
|
|
28
|
+
{ currency: body.currency ?? "USD", metadata: { posTransaction: true } },
|
|
29
|
+
actor,
|
|
30
|
+
);
|
|
31
|
+
if (!cartResult.ok || !cartResult.value) {
|
|
32
|
+
throw new Error("Failed to create cart for POS transaction");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const result = await service.create(orgId, {
|
|
36
|
+
shiftId: body.shiftId,
|
|
37
|
+
terminalId: body.terminalId,
|
|
38
|
+
operatorId: actor!.userId,
|
|
39
|
+
cartId: cartResult.value.id,
|
|
40
|
+
...(body.type != null ? { type: body.type } : {}),
|
|
41
|
+
...(body.customerId != null ? { customerId: body.customerId } : {}),
|
|
42
|
+
});
|
|
43
|
+
if (!result.ok) throw new Error(result.error);
|
|
44
|
+
return result.value;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// NOTE: /held must be registered BEFORE /{id} to avoid UUID validation catching "held"
|
|
48
|
+
r.get("/held")
|
|
49
|
+
.summary("List held transactions")
|
|
50
|
+
.permission("pos:operate")
|
|
51
|
+
.query(z.object({
|
|
52
|
+
terminalId: z.string().uuid(),
|
|
53
|
+
}))
|
|
54
|
+
.handler(async ({ query, orgId }) => {
|
|
55
|
+
const q = query as { terminalId: string };
|
|
56
|
+
const result = await service.listHeld(orgId, q.terminalId);
|
|
57
|
+
if (!result.ok) throw new Error(result.error);
|
|
58
|
+
return result.value;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
r.get("/{id}")
|
|
62
|
+
.summary("Get transaction")
|
|
63
|
+
.permission("pos:operate")
|
|
64
|
+
.handler(async ({ params, orgId }) => {
|
|
65
|
+
const result = await service.getById(orgId, params.id!);
|
|
66
|
+
if (!result.ok) throw new Error(result.error);
|
|
67
|
+
return result.value;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
r.post("/{id}/items")
|
|
71
|
+
.summary("Add item to transaction")
|
|
72
|
+
.permission("pos:operate")
|
|
73
|
+
.input(z.object({
|
|
74
|
+
entityId: z.string().uuid(),
|
|
75
|
+
variantId: z.string().uuid().optional(),
|
|
76
|
+
quantity: z.number().int().min(1).max(9999).optional(),
|
|
77
|
+
notes: z.string().max(500).optional(),
|
|
78
|
+
}))
|
|
79
|
+
.handler(async ({ params, input, actor, services, orgId }) => {
|
|
80
|
+
const body = input as { entityId: string; variantId?: string; quantity?: number; notes?: string };
|
|
81
|
+
const txnResult = await service.getById(orgId, params.id!);
|
|
82
|
+
if (!txnResult.ok) throw new Error(txnResult.error);
|
|
83
|
+
if (txnResult.value.status !== "open") throw new Error("Transaction is not open");
|
|
84
|
+
|
|
85
|
+
// Delegate to cart service
|
|
86
|
+
const cart = services.cart as { addItem: (input: unknown, actor: unknown) => Promise<{ ok: boolean; value?: unknown }> };
|
|
87
|
+
const addResult = await cart.addItem({
|
|
88
|
+
cartId: txnResult.value.cartId,
|
|
89
|
+
entityId: body.entityId,
|
|
90
|
+
variantId: body.variantId,
|
|
91
|
+
quantity: body.quantity ?? 1,
|
|
92
|
+
notes: body.notes,
|
|
93
|
+
}, actor);
|
|
94
|
+
|
|
95
|
+
if (!addResult.ok) throw new Error("Failed to add item");
|
|
96
|
+
return addResult.value;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
r.patch("/{id}/items/{itemId}")
|
|
100
|
+
.summary("Update line item")
|
|
101
|
+
.permission("pos:operate")
|
|
102
|
+
.input(z.object({
|
|
103
|
+
quantity: z.number().int().min(1).max(9999).optional(),
|
|
104
|
+
notes: z.string().max(500).optional(),
|
|
105
|
+
}))
|
|
106
|
+
.handler(async ({ params, input, actor, services, orgId }) => {
|
|
107
|
+
const body = input as { quantity?: number; notes?: string };
|
|
108
|
+
const txnResult = await service.getById(orgId, params.id!);
|
|
109
|
+
if (!txnResult.ok) throw new Error(txnResult.error);
|
|
110
|
+
if (txnResult.value.status !== "open") throw new Error("Transaction is not open");
|
|
111
|
+
|
|
112
|
+
const cart = services.cart as {
|
|
113
|
+
updateQuantity: (input: { cartId: string; itemId: string; quantity: number }, actor: unknown) => Promise<{ ok: boolean; value?: unknown }>;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (body.quantity != null) {
|
|
117
|
+
const updateResult = await cart.updateQuantity({
|
|
118
|
+
cartId: txnResult.value.cartId,
|
|
119
|
+
itemId: params.itemId!,
|
|
120
|
+
quantity: body.quantity,
|
|
121
|
+
}, actor);
|
|
122
|
+
if (!updateResult.ok) throw new Error("Failed to update item");
|
|
123
|
+
return updateResult.value;
|
|
124
|
+
}
|
|
125
|
+
return { updated: true };
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
r.delete("/{id}/items/{itemId}")
|
|
129
|
+
.summary("Remove line item")
|
|
130
|
+
.permission("pos:operate")
|
|
131
|
+
.handler(async ({ params, actor, services, orgId }) => {
|
|
132
|
+
const txnResult = await service.getById(orgId, params.id!);
|
|
133
|
+
if (!txnResult.ok) throw new Error(txnResult.error);
|
|
134
|
+
if (txnResult.value.status !== "open") throw new Error("Transaction is not open");
|
|
135
|
+
|
|
136
|
+
const cart = services.cart as {
|
|
137
|
+
removeItem: (cartId: string, itemId: string, actor: unknown) => Promise<{ ok: boolean }>;
|
|
138
|
+
};
|
|
139
|
+
await cart.removeItem(txnResult.value.cartId, params.itemId!, actor);
|
|
140
|
+
return { removed: true };
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
r.post("/{id}/customer")
|
|
144
|
+
.summary("Associate customer")
|
|
145
|
+
.permission("pos:operate")
|
|
146
|
+
.input(z.object({
|
|
147
|
+
customerId: z.string().uuid(),
|
|
148
|
+
}))
|
|
149
|
+
.handler(async ({ params, input, orgId }) => {
|
|
150
|
+
const body = input as { customerId: string };
|
|
151
|
+
const result = await service.setCustomer(orgId, params.id!, body.customerId);
|
|
152
|
+
if (!result.ok) throw new Error(result.error);
|
|
153
|
+
return result.value;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
r.post("/{id}/hold")
|
|
157
|
+
.summary("Hold transaction")
|
|
158
|
+
.permission("pos:operate")
|
|
159
|
+
.input(z.object({
|
|
160
|
+
label: z.string().min(1).max(200),
|
|
161
|
+
}))
|
|
162
|
+
.handler(async ({ params, input, orgId }) => {
|
|
163
|
+
const body = input as { label: string };
|
|
164
|
+
const result = await service.hold(orgId, params.id!, body.label);
|
|
165
|
+
if (!result.ok) throw new Error(result.error);
|
|
166
|
+
return result.value;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
r.post("/{id}/recall")
|
|
170
|
+
.summary("Recall held transaction")
|
|
171
|
+
.permission("pos:operate")
|
|
172
|
+
.handler(async ({ params, orgId }) => {
|
|
173
|
+
const result = await service.recall(orgId, params.id!);
|
|
174
|
+
if (!result.ok) throw new Error(result.error);
|
|
175
|
+
return result.value;
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
r.post("/{id}/void")
|
|
179
|
+
.summary("Void transaction")
|
|
180
|
+
.permission("pos:manage")
|
|
181
|
+
.input(z.object({
|
|
182
|
+
reason: z.string().min(1).max(500),
|
|
183
|
+
}))
|
|
184
|
+
.handler(async ({ params, input, orgId }) => {
|
|
185
|
+
const body = input as { reason: string };
|
|
186
|
+
const result = await service.void(orgId, params.id!, body.reason);
|
|
187
|
+
if (!result.ok) throw new Error(result.error);
|
|
188
|
+
return result.value;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return r.routes();
|
|
192
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POS Plugin Schema — RFC-023 Tier 0 Core Primitives
|
|
3
|
+
*
|
|
4
|
+
* 6 tables replacing the old single pos_sessions table:
|
|
5
|
+
* - pos_terminals: physical register/device registry
|
|
6
|
+
* - pos_shifts: operator working periods with cash tracking
|
|
7
|
+
* - pos_cash_events: cash drawer operations within a shift
|
|
8
|
+
* - pos_transactions: individual sales, returns, exchanges
|
|
9
|
+
* - pos_payments: payment records (supports split tender)
|
|
10
|
+
* - pos_return_items: links return transactions to original order line items
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { pgTable, uuid, text, integer, boolean, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
|
|
14
|
+
|
|
15
|
+
// ─── Terminals ──────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export const posTerminals = pgTable("pos_terminals", {
|
|
18
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
19
|
+
organizationId: text("organization_id").notNull(),
|
|
20
|
+
name: text("name").notNull(),
|
|
21
|
+
code: text("code").notNull(),
|
|
22
|
+
type: text("type", { enum: ["register", "tablet", "mobile", "kiosk"] }).notNull().default("register"),
|
|
23
|
+
isActive: boolean("is_active").notNull().default(true),
|
|
24
|
+
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
|
25
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
26
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
27
|
+
}, (table) => ({
|
|
28
|
+
orgIdx: index("idx_pos_terminals_org").on(table.organizationId),
|
|
29
|
+
codeUnique: uniqueIndex("pos_terminals_org_code_unique").on(table.organizationId, table.code),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// ─── Shifts ─────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export const posShifts = pgTable("pos_shifts", {
|
|
35
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
36
|
+
organizationId: text("organization_id").notNull(),
|
|
37
|
+
terminalId: uuid("terminal_id").references(() => posTerminals.id, { onDelete: "cascade" }).notNull(),
|
|
38
|
+
operatorId: text("operator_id").notNull(),
|
|
39
|
+
status: text("status", { enum: ["open", "closed"] }).notNull().default("open"),
|
|
40
|
+
openingFloat: integer("opening_float").notNull().default(0),
|
|
41
|
+
closingCount: integer("closing_count"),
|
|
42
|
+
expectedCash: integer("expected_cash"),
|
|
43
|
+
cashVariance: integer("cash_variance"),
|
|
44
|
+
salesCount: integer("sales_count").notNull().default(0),
|
|
45
|
+
salesTotal: integer("sales_total").notNull().default(0),
|
|
46
|
+
refundsCount: integer("refunds_count").notNull().default(0),
|
|
47
|
+
refundsTotal: integer("refunds_total").notNull().default(0),
|
|
48
|
+
voidsCount: integer("voids_count").notNull().default(0),
|
|
49
|
+
closedAt: timestamp("closed_at", { withTimezone: true }),
|
|
50
|
+
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
|
51
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
52
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
53
|
+
}, (table) => ({
|
|
54
|
+
orgIdx: index("idx_pos_shifts_org").on(table.organizationId),
|
|
55
|
+
terminalIdx: index("idx_pos_shifts_terminal").on(table.terminalId),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
// ─── Cash Events ────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export const posCashEvents = pgTable("pos_cash_events", {
|
|
61
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
62
|
+
shiftId: uuid("shift_id").references(() => posShifts.id, { onDelete: "cascade" }).notNull(),
|
|
63
|
+
type: text("type", { enum: ["float", "drop", "pickup", "paid_in", "paid_out"] }).notNull(),
|
|
64
|
+
amount: integer("amount").notNull(),
|
|
65
|
+
reason: text("reason"),
|
|
66
|
+
performedBy: text("performed_by").notNull(),
|
|
67
|
+
performedAt: timestamp("performed_at", { withTimezone: true }).defaultNow().notNull(),
|
|
68
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
69
|
+
}, (table) => ({
|
|
70
|
+
shiftIdx: index("idx_pos_cash_events_shift").on(table.shiftId),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
// ─── Transactions ───────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export const posTransactions = pgTable("pos_transactions", {
|
|
76
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
77
|
+
organizationId: text("organization_id").notNull(),
|
|
78
|
+
shiftId: uuid("shift_id").references(() => posShifts.id, { onDelete: "cascade" }).notNull(),
|
|
79
|
+
terminalId: uuid("terminal_id").references(() => posTerminals.id).notNull(),
|
|
80
|
+
operatorId: text("operator_id").notNull(),
|
|
81
|
+
cartId: uuid("cart_id").notNull(),
|
|
82
|
+
orderId: uuid("order_id"),
|
|
83
|
+
type: text("type", { enum: ["sale", "return", "exchange"] }).notNull().default("sale"),
|
|
84
|
+
status: text("status", { enum: ["open", "held", "completed", "voided"] }).notNull().default("open"),
|
|
85
|
+
customerId: uuid("customer_id"),
|
|
86
|
+
receiptNumber: text("receipt_number").notNull(),
|
|
87
|
+
subtotal: integer("subtotal").notNull().default(0),
|
|
88
|
+
taxTotal: integer("tax_total").notNull().default(0),
|
|
89
|
+
total: integer("total").notNull().default(0),
|
|
90
|
+
discountTotal: integer("discount_total").notNull().default(0),
|
|
91
|
+
holdLabel: text("hold_label"),
|
|
92
|
+
voidReason: text("void_reason"),
|
|
93
|
+
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
|
94
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
95
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
96
|
+
completedAt: timestamp("completed_at", { withTimezone: true }),
|
|
97
|
+
}, (table) => ({
|
|
98
|
+
orgIdx: index("idx_pos_transactions_org").on(table.organizationId),
|
|
99
|
+
shiftIdx: index("idx_pos_transactions_shift").on(table.shiftId),
|
|
100
|
+
statusIdx: index("idx_pos_transactions_status").on(table.status),
|
|
101
|
+
receiptIdx: index("idx_pos_transactions_receipt").on(table.receiptNumber),
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
// ─── Payments (Tenders) ─────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
export const posPayments = pgTable("pos_payments", {
|
|
107
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
108
|
+
transactionId: uuid("transaction_id").references(() => posTransactions.id, { onDelete: "cascade" }).notNull(),
|
|
109
|
+
method: text("method", { enum: ["cash", "card", "gift_card", "store_credit", "other"] }).notNull(),
|
|
110
|
+
amount: integer("amount").notNull(),
|
|
111
|
+
changeGiven: integer("change_given").notNull().default(0),
|
|
112
|
+
reference: text("reference"),
|
|
113
|
+
status: text("status", { enum: ["collected", "refunded"] }).notNull().default("collected"),
|
|
114
|
+
processedAt: timestamp("processed_at", { withTimezone: true }).defaultNow().notNull(),
|
|
115
|
+
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
|
116
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
117
|
+
}, (table) => ({
|
|
118
|
+
transactionIdx: index("idx_pos_payments_transaction").on(table.transactionId),
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
// ─── Return Items ───────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
export const posReturnItems = pgTable("pos_return_items", {
|
|
124
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
125
|
+
transactionId: uuid("transaction_id").references(() => posTransactions.id, { onDelete: "cascade" }).notNull(),
|
|
126
|
+
originalOrderId: uuid("original_order_id").notNull(),
|
|
127
|
+
originalLineItemId: uuid("original_line_item_id").notNull(),
|
|
128
|
+
quantity: integer("quantity").notNull(),
|
|
129
|
+
reason: text("reason", { enum: ["defective", "wrong_item", "changed_mind", "other"] }).notNull(),
|
|
130
|
+
restockingFee: integer("restocking_fee").notNull().default(0),
|
|
131
|
+
refundAmount: integer("refund_amount").notNull(),
|
|
132
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
133
|
+
}, (table) => ({
|
|
134
|
+
transactionIdx: index("idx_pos_return_items_transaction").on(table.transactionId),
|
|
135
|
+
originalOrderIdx: index("idx_pos_return_items_order").on(table.originalOrderId),
|
|
136
|
+
}));
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { eq, and } from "drizzle-orm";
|
|
2
|
+
import { Ok, Err } from "@unifiedcommerce/core";
|
|
3
|
+
import type { PluginResult } from "@unifiedcommerce/core";
|
|
4
|
+
import { variants, sellableEntities } from "@unifiedcommerce/core/schema";
|
|
5
|
+
import type { Db } from "../types";
|
|
6
|
+
|
|
7
|
+
export interface LookupResult {
|
|
8
|
+
entityId: string;
|
|
9
|
+
variantId: string;
|
|
10
|
+
entityType: string;
|
|
11
|
+
slug: string;
|
|
12
|
+
barcode: string | null;
|
|
13
|
+
sku: string | null;
|
|
14
|
+
title?: string | undefined;
|
|
15
|
+
price?: number | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Item lookup service for POS.
|
|
20
|
+
* Uses indexed queries on variants.barcode and variants.sku.
|
|
21
|
+
*
|
|
22
|
+
* Depends on core schema tables (variants, sellable_entities, entity_attributes)
|
|
23
|
+
* accessed via the scoped DB proxy.
|
|
24
|
+
*/
|
|
25
|
+
export class LookupService {
|
|
26
|
+
constructor(
|
|
27
|
+
private db: Db,
|
|
28
|
+
private services: Record<string, unknown>,
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Find entity + variant by barcode. Single indexed query.
|
|
33
|
+
*/
|
|
34
|
+
async byBarcode(orgId: string, barcode: string): Promise<PluginResult<LookupResult>> {
|
|
35
|
+
|
|
36
|
+
const rows = await this.db
|
|
37
|
+
.select({
|
|
38
|
+
variantId: variants.id,
|
|
39
|
+
entityId: variants.entityId,
|
|
40
|
+
barcode: variants.barcode,
|
|
41
|
+
sku: variants.sku,
|
|
42
|
+
entityType: sellableEntities.type,
|
|
43
|
+
slug: sellableEntities.slug,
|
|
44
|
+
orgId: sellableEntities.organizationId,
|
|
45
|
+
})
|
|
46
|
+
.from(variants)
|
|
47
|
+
.innerJoin(sellableEntities, eq(variants.entityId, sellableEntities.id))
|
|
48
|
+
.where(and(
|
|
49
|
+
eq(variants.barcode, barcode),
|
|
50
|
+
eq(sellableEntities.organizationId, orgId),
|
|
51
|
+
))
|
|
52
|
+
.limit(1);
|
|
53
|
+
|
|
54
|
+
if (rows.length === 0) return Err("No item found for barcode");
|
|
55
|
+
|
|
56
|
+
const row = rows[0]!;
|
|
57
|
+
return Ok({
|
|
58
|
+
entityId: row.entityId,
|
|
59
|
+
variantId: row.variantId,
|
|
60
|
+
entityType: row.entityType,
|
|
61
|
+
slug: row.slug,
|
|
62
|
+
barcode: row.barcode,
|
|
63
|
+
sku: row.sku,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Find entity + variant by SKU. Single indexed query.
|
|
69
|
+
*/
|
|
70
|
+
async bySku(orgId: string, sku: string): Promise<PluginResult<LookupResult>> {
|
|
71
|
+
const rows = await this.db
|
|
72
|
+
.select({
|
|
73
|
+
variantId: variants.id,
|
|
74
|
+
entityId: variants.entityId,
|
|
75
|
+
barcode: variants.barcode,
|
|
76
|
+
sku: variants.sku,
|
|
77
|
+
entityType: sellableEntities.type,
|
|
78
|
+
slug: sellableEntities.slug,
|
|
79
|
+
orgId: sellableEntities.organizationId,
|
|
80
|
+
})
|
|
81
|
+
.from(variants)
|
|
82
|
+
.innerJoin(sellableEntities, eq(variants.entityId, sellableEntities.id))
|
|
83
|
+
.where(and(
|
|
84
|
+
eq(variants.sku, sku),
|
|
85
|
+
eq(sellableEntities.organizationId, orgId),
|
|
86
|
+
))
|
|
87
|
+
.limit(1);
|
|
88
|
+
|
|
89
|
+
if (rows.length === 0) return Err("No item found for SKU");
|
|
90
|
+
|
|
91
|
+
const row = rows[0]!;
|
|
92
|
+
return Ok({
|
|
93
|
+
entityId: row.entityId,
|
|
94
|
+
variantId: row.variantId,
|
|
95
|
+
entityType: row.entityType,
|
|
96
|
+
slug: row.slug,
|
|
97
|
+
barcode: row.barcode,
|
|
98
|
+
sku: row.sku,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Quick text search across entity attributes (name, title).
|
|
104
|
+
* Delegates to the core search service.
|
|
105
|
+
*/
|
|
106
|
+
async search(orgId: string, query: string): Promise<PluginResult<LookupResult[]>> {
|
|
107
|
+
const searchService = this.services.search as {
|
|
108
|
+
search: (params: { query: string; organizationId?: string; limit?: number }) => Promise<{
|
|
109
|
+
ok: boolean;
|
|
110
|
+
value?: { results: Array<{ entityId: string; type: string; slug: string; title?: string }> };
|
|
111
|
+
}>;
|
|
112
|
+
} | undefined;
|
|
113
|
+
|
|
114
|
+
if (!searchService) {
|
|
115
|
+
return Ok([]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const result = await searchService.search({
|
|
119
|
+
query,
|
|
120
|
+
organizationId: orgId,
|
|
121
|
+
limit: 20,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!result.ok || !result.value) return Ok([]);
|
|
125
|
+
|
|
126
|
+
return Ok(result.value.results.map((r): LookupResult => ({
|
|
127
|
+
entityId: r.entityId,
|
|
128
|
+
variantId: "",
|
|
129
|
+
entityType: r.type,
|
|
130
|
+
slug: r.slug,
|
|
131
|
+
barcode: null,
|
|
132
|
+
sku: null,
|
|
133
|
+
title: r.title ?? undefined,
|
|
134
|
+
})));
|
|
135
|
+
}
|
|
136
|
+
}
|