@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,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
|
+
};
|