@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.
Files changed (77) hide show
  1. package/dist/hooks/checkout-pos.d.ts +29 -0
  2. package/dist/hooks/checkout-pos.d.ts.map +1 -0
  3. package/dist/hooks/checkout-pos.js +69 -0
  4. package/dist/index.d.ts +26 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +109 -0
  7. package/dist/payment-adapter.d.ts +33 -0
  8. package/dist/payment-adapter.d.ts.map +1 -0
  9. package/dist/payment-adapter.js +37 -0
  10. package/dist/routes/lookup.d.ts +9 -0
  11. package/dist/routes/lookup.d.ts.map +1 -0
  12. package/dist/routes/lookup.js +37 -0
  13. package/dist/routes/payments.d.ts +10 -0
  14. package/dist/routes/payments.d.ts.map +1 -0
  15. package/dist/routes/payments.js +62 -0
  16. package/dist/routes/receipts.d.ts +9 -0
  17. package/dist/routes/receipts.d.ts.map +1 -0
  18. package/dist/routes/receipts.js +28 -0
  19. package/dist/routes/returns.d.ts +21 -0
  20. package/dist/routes/returns.d.ts.map +1 -0
  21. package/dist/routes/returns.js +83 -0
  22. package/dist/routes/shifts.d.ts +9 -0
  23. package/dist/routes/shifts.d.ts.map +1 -0
  24. package/dist/routes/shifts.js +91 -0
  25. package/dist/routes/terminals.d.ts +9 -0
  26. package/dist/routes/terminals.d.ts.map +1 -0
  27. package/dist/routes/terminals.js +55 -0
  28. package/dist/routes/transactions.d.ts +19 -0
  29. package/dist/routes/transactions.d.ts.map +1 -0
  30. package/dist/routes/transactions.js +175 -0
  31. package/dist/schema.d.ts +1337 -0
  32. package/dist/schema.d.ts.map +1 -0
  33. package/dist/schema.js +123 -0
  34. package/dist/services/lookup-service.d.ts +38 -0
  35. package/dist/services/lookup-service.d.ts.map +1 -0
  36. package/dist/services/lookup-service.js +104 -0
  37. package/dist/services/payment-service.d.ts +40 -0
  38. package/dist/services/payment-service.d.ts.map +1 -0
  39. package/dist/services/payment-service.js +99 -0
  40. package/dist/services/receipt-service.d.ts +45 -0
  41. package/dist/services/receipt-service.d.ts.map +1 -0
  42. package/dist/services/receipt-service.js +119 -0
  43. package/dist/services/return-service.d.ts +27 -0
  44. package/dist/services/return-service.d.ts.map +1 -0
  45. package/dist/services/return-service.js +51 -0
  46. package/dist/services/shift-service.d.ts +36 -0
  47. package/dist/services/shift-service.d.ts.map +1 -0
  48. package/dist/services/shift-service.js +198 -0
  49. package/dist/services/terminal-service.d.ts +21 -0
  50. package/dist/services/terminal-service.d.ts.map +1 -0
  51. package/dist/services/terminal-service.js +59 -0
  52. package/dist/services/transaction-service.d.ts +30 -0
  53. package/dist/services/transaction-service.d.ts.map +1 -0
  54. package/dist/services/transaction-service.js +202 -0
  55. package/dist/types.d.ts +30 -0
  56. package/dist/types.d.ts.map +1 -0
  57. package/dist/types.js +5 -0
  58. package/package.json +40 -0
  59. package/src/hooks/checkout-pos.ts +93 -0
  60. package/src/index.ts +131 -0
  61. package/src/payment-adapter.ts +53 -0
  62. package/src/routes/lookup.ts +44 -0
  63. package/src/routes/payments.ts +82 -0
  64. package/src/routes/receipts.ts +35 -0
  65. package/src/routes/returns.ts +116 -0
  66. package/src/routes/shifts.ts +100 -0
  67. package/src/routes/terminals.ts +62 -0
  68. package/src/routes/transactions.ts +192 -0
  69. package/src/schema.ts +136 -0
  70. package/src/services/lookup-service.ts +136 -0
  71. package/src/services/payment-service.ts +133 -0
  72. package/src/services/receipt-service.ts +169 -0
  73. package/src/services/return-service.ts +65 -0
  74. package/src/services/shift-service.ts +260 -0
  75. package/src/services/terminal-service.ts +76 -0
  76. package/src/services/transaction-service.ts +248 -0
  77. package/src/types.ts +49 -0
@@ -0,0 +1,248 @@
1
+ import { eq, and, desc, sql } from "drizzle-orm";
2
+ import { Ok, Err } from "@unifiedcommerce/core";
3
+ import type { PluginResult } from "@unifiedcommerce/core";
4
+ import { posTransactions, posPayments, posShifts } from "../schema";
5
+ import type { Db, Transaction, TransactionInsert } from "../types";
6
+
7
+ export class TransactionService {
8
+ constructor(
9
+ private db: Db,
10
+ private transaction: (fn: (tx: Db) => Promise<unknown>) => Promise<unknown>,
11
+ ) {}
12
+
13
+ async create(orgId: string, input: {
14
+ shiftId: string;
15
+ terminalId: string;
16
+ operatorId: string;
17
+ cartId: string;
18
+ type?: "sale" | "return" | "exchange";
19
+ customerId?: string;
20
+ }): Promise<PluginResult<Transaction>> {
21
+ // Verify shift is open
22
+ const shifts = await this.db
23
+ .select()
24
+ .from(posShifts)
25
+ .where(and(eq(posShifts.id, input.shiftId), eq(posShifts.status, "open")));
26
+ if (shifts.length === 0) return Err("Shift is not open");
27
+
28
+ const receiptNumber = await this.generateReceiptNumber(input.terminalId);
29
+
30
+ const rows = await this.db
31
+ .insert(posTransactions)
32
+ .values({
33
+ organizationId: orgId,
34
+ shiftId: input.shiftId,
35
+ terminalId: input.terminalId,
36
+ operatorId: input.operatorId,
37
+ cartId: input.cartId,
38
+ type: input.type ?? "sale",
39
+ status: "open",
40
+ customerId: input.customerId,
41
+ receiptNumber,
42
+ } as TransactionInsert)
43
+ .returning();
44
+
45
+ return Ok(rows[0]!);
46
+ }
47
+
48
+ async getById(orgId: string, id: string): Promise<PluginResult<Transaction>> {
49
+ const rows = await this.db
50
+ .select()
51
+ .from(posTransactions)
52
+ .where(and(eq(posTransactions.id, id), eq(posTransactions.organizationId, orgId)));
53
+
54
+ if (rows.length === 0) return Err("Transaction not found");
55
+ return Ok(rows[0]!);
56
+ }
57
+
58
+ async hold(orgId: string, id: string, label: string): Promise<PluginResult<Transaction>> {
59
+ const txnResult = await this.getById(orgId, id);
60
+ if (!txnResult.ok) return txnResult;
61
+ if (txnResult.value.status !== "open") return Err("Only open transactions can be held");
62
+
63
+ const rows = await this.db
64
+ .update(posTransactions)
65
+ .set({ status: "held", holdLabel: label, updatedAt: new Date() })
66
+ .where(eq(posTransactions.id, id))
67
+ .returning();
68
+
69
+ return Ok(rows[0]!);
70
+ }
71
+
72
+ async recall(orgId: string, id: string): Promise<PluginResult<Transaction>> {
73
+ const txnResult = await this.getById(orgId, id);
74
+ if (!txnResult.ok) return txnResult;
75
+ if (txnResult.value.status !== "held") return Err("Only held transactions can be recalled");
76
+
77
+ const rows = await this.db
78
+ .update(posTransactions)
79
+ .set({ status: "open", holdLabel: null, updatedAt: new Date() })
80
+ .where(eq(posTransactions.id, id))
81
+ .returning();
82
+
83
+ return Ok(rows[0]!);
84
+ }
85
+
86
+ async listHeld(orgId: string, terminalId: string): Promise<PluginResult<Transaction[]>> {
87
+ const rows = await this.db
88
+ .select()
89
+ .from(posTransactions)
90
+ .where(and(
91
+ eq(posTransactions.organizationId, orgId),
92
+ eq(posTransactions.terminalId, terminalId),
93
+ eq(posTransactions.status, "held"),
94
+ ))
95
+ .orderBy(desc(posTransactions.createdAt));
96
+
97
+ return Ok(rows);
98
+ }
99
+
100
+ async void(orgId: string, id: string, reason: string): Promise<PluginResult<Transaction>> {
101
+ const result = await this.transaction(async (tx) => {
102
+ const txns = await tx
103
+ .select()
104
+ .from(posTransactions)
105
+ .where(and(eq(posTransactions.id, id), eq(posTransactions.organizationId, orgId)))
106
+ .for("update");
107
+
108
+ if (txns.length === 0) return Err("Transaction not found");
109
+ const txn = txns[0]!;
110
+
111
+ if (txn.status === "completed") return Err("Cannot void a completed transaction");
112
+ if (txn.status === "voided") return Err("Transaction is already voided");
113
+
114
+ const updated = await tx
115
+ .update(posTransactions)
116
+ .set({ status: "voided", voidReason: reason, updatedAt: new Date() })
117
+ .where(eq(posTransactions.id, id))
118
+ .returning();
119
+
120
+ // Increment void count on shift
121
+ await tx
122
+ .update(posShifts)
123
+ .set({ voidsCount: sql`${posShifts.voidsCount} + 1`, updatedAt: new Date() })
124
+ .where(eq(posShifts.id, txn.shiftId));
125
+
126
+ return Ok(updated[0]!);
127
+ });
128
+
129
+ return result as PluginResult<Transaction>;
130
+ }
131
+
132
+ async setCustomer(orgId: string, id: string, customerId: string): Promise<PluginResult<Transaction>> {
133
+ const txnResult = await this.getById(orgId, id);
134
+ if (!txnResult.ok) return txnResult;
135
+ if (txnResult.value.status !== "open") return Err("Can only set customer on open transactions");
136
+
137
+ const rows = await this.db
138
+ .update(posTransactions)
139
+ .set({ customerId, updatedAt: new Date() })
140
+ .where(eq(posTransactions.id, id))
141
+ .returning();
142
+
143
+ return Ok(rows[0]!);
144
+ }
145
+
146
+ async updateTotals(id: string, totals: {
147
+ subtotal: number;
148
+ taxTotal: number;
149
+ total: number;
150
+ discountTotal: number;
151
+ }): Promise<void> {
152
+ await this.db
153
+ .update(posTransactions)
154
+ .set({ ...totals, updatedAt: new Date() })
155
+ .where(eq(posTransactions.id, id));
156
+ }
157
+
158
+ async complete(id: string, orderId: string | null): Promise<PluginResult<Transaction>> {
159
+ const result = await this.transaction(async (tx) => {
160
+ // Lock and verify transaction is in a completable state
161
+ const existing = await tx
162
+ .select()
163
+ .from(posTransactions)
164
+ .where(eq(posTransactions.id, id))
165
+ .for("update");
166
+
167
+ if (existing.length === 0) return Err("Transaction not found");
168
+ const current = existing[0]!;
169
+ if (current.status === "completed") return Err("Transaction is already completed");
170
+ if (current.status === "voided") return Err("Cannot complete a voided transaction");
171
+ if (current.status !== "open") return Err(`Cannot complete transaction in '${current.status}' status`);
172
+
173
+ // Sum collected payments to derive transaction total
174
+ const paymentRows = await tx
175
+ .select({
176
+ total: sql<number>`COALESCE(SUM(${posPayments.amount} - ${posPayments.changeGiven}), 0)`.as("total"),
177
+ })
178
+ .from(posPayments)
179
+ .where(and(
180
+ eq(posPayments.transactionId, id),
181
+ eq(posPayments.status, "collected"),
182
+ ));
183
+ const paymentTotal = Number(paymentRows[0]?.total ?? 0);
184
+
185
+ const rows = await tx
186
+ .update(posTransactions)
187
+ .set({
188
+ status: "completed",
189
+ subtotal: paymentTotal,
190
+ total: paymentTotal,
191
+ ...(orderId != null ? { orderId } : {}),
192
+ completedAt: new Date(),
193
+ updatedAt: new Date(),
194
+ })
195
+ .where(eq(posTransactions.id, id))
196
+ .returning();
197
+
198
+ if (rows.length === 0) return Err("Transaction not found");
199
+ const txn = rows[0]!;
200
+
201
+ // Update shift sales counters
202
+ if (txn.type === "sale" && paymentTotal > 0) {
203
+ await tx
204
+ .update(posShifts)
205
+ .set({
206
+ salesCount: sql`${posShifts.salesCount} + 1`,
207
+ salesTotal: sql`${posShifts.salesTotal} + ${paymentTotal}`,
208
+ updatedAt: new Date(),
209
+ })
210
+ .where(eq(posShifts.id, txn.shiftId));
211
+ }
212
+
213
+ return Ok(txn);
214
+ });
215
+
216
+ return result as PluginResult<Transaction>;
217
+ }
218
+
219
+ // ─── Receipt Number Generation ─────────────────────────────────────
220
+ // Sequential per terminal per day: {terminal_code}-{sequence}
221
+
222
+ private async generateReceiptNumber(terminalId: string): Promise<string> {
223
+ const today = new Date();
224
+ const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
225
+ const startOfDayISO = startOfDay.toISOString();
226
+
227
+ // Get terminal code
228
+ const { posTerminals } = await import("../schema");
229
+ const terminals = await this.db
230
+ .select({ code: posTerminals.code })
231
+ .from(posTerminals)
232
+ .where(eq(posTerminals.id, terminalId));
233
+
234
+ const terminalCode = terminals[0]?.code ?? "POS";
235
+
236
+ // Count today's transactions for this terminal
237
+ const countRows = await this.db
238
+ .select({ count: sql<number>`COUNT(*)`.as("count") })
239
+ .from(posTransactions)
240
+ .where(and(
241
+ eq(posTransactions.terminalId, terminalId),
242
+ sql`${posTransactions.createdAt} >= ${startOfDayISO}::timestamptz`,
243
+ ));
244
+
245
+ const seq = Number(countRows[0]?.count ?? 0) + 1;
246
+ return `${terminalCode}-${String(seq).padStart(4, "0")}`;
247
+ }
248
+ }
package/src/types.ts ADDED
@@ -0,0 +1,49 @@
1
+ export type { PluginDb as Db } from "@unifiedcommerce/core";
2
+ import type {
3
+ posTerminals,
4
+ posShifts,
5
+ posCashEvents,
6
+ posTransactions,
7
+ posPayments,
8
+ posReturnItems,
9
+ } from "./schema";
10
+
11
+ export type Terminal = typeof posTerminals.$inferSelect;
12
+ export type TerminalInsert = typeof posTerminals.$inferInsert;
13
+
14
+ export type Shift = typeof posShifts.$inferSelect;
15
+ export type ShiftInsert = typeof posShifts.$inferInsert;
16
+
17
+ export type CashEvent = typeof posCashEvents.$inferSelect;
18
+ export type CashEventInsert = typeof posCashEvents.$inferInsert;
19
+
20
+ export type Transaction = typeof posTransactions.$inferSelect;
21
+ export type TransactionInsert = typeof posTransactions.$inferInsert;
22
+
23
+ export type Payment = typeof posPayments.$inferSelect;
24
+ export type PaymentInsert = typeof posPayments.$inferInsert;
25
+
26
+ export type ReturnItem = typeof posReturnItems.$inferSelect;
27
+ export type ReturnItemInsert = typeof posReturnItems.$inferInsert;
28
+
29
+ export type TransactionStatus = "open" | "held" | "completed" | "voided";
30
+ export type TransactionType = "sale" | "return" | "exchange";
31
+ export type PaymentMethod = "cash" | "card" | "gift_card" | "store_credit" | "other";
32
+ export type CashEventType = "float" | "drop" | "pickup" | "paid_in" | "paid_out";
33
+ export type ShiftStatus = "open" | "closed";
34
+ export type TerminalType = "register" | "tablet" | "mobile" | "kiosk";
35
+
36
+ export interface POSPluginOptions {
37
+ /** Default currency for new transactions. Default: "USD" */
38
+ defaultCurrency?: string;
39
+ /** Maximum hold duration in hours before auto-void. Default: 24 */
40
+ maxHoldHours?: number;
41
+ /** Require manager override for discounts above this percentage. Default: 20 */
42
+ discountOverrideThreshold?: number;
43
+ }
44
+
45
+ export const DEFAULT_POS_OPTIONS: Required<POSPluginOptions> = {
46
+ defaultCurrency: "USD",
47
+ maxHoldHours: 24,
48
+ discountOverrideThreshold: 20,
49
+ };