@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,133 @@
1
+ import { eq, and, sql } from "drizzle-orm";
2
+ import { Ok, Err } from "@unifiedcommerce/core";
3
+ import type { PluginResult } from "@unifiedcommerce/core";
4
+ import { posPayments, posTransactions, posShifts } from "../schema";
5
+ import type { Db, Payment, Transaction } from "../types";
6
+
7
+ export class PaymentService {
8
+ constructor(
9
+ private db: Db,
10
+ private transaction: (fn: (tx: Db) => Promise<unknown>) => Promise<unknown>,
11
+ ) {}
12
+
13
+ /**
14
+ * Add a payment to a transaction. Does NOT finalize.
15
+ * Supports split payment (multiple calls per transaction).
16
+ */
17
+ async addPayment(orgId: string, transactionId: string, input: {
18
+ method: "cash" | "card" | "gift_card" | "store_credit" | "other";
19
+ amount: number;
20
+ changeGiven?: number;
21
+ reference?: string;
22
+ metadata?: Record<string, unknown>;
23
+ }): Promise<PluginResult<Payment>> {
24
+ if (input.amount <= 0) return Err("Payment amount must be positive");
25
+
26
+ // Verify transaction is open
27
+ const txns = await this.db
28
+ .select()
29
+ .from(posTransactions)
30
+ .where(and(
31
+ eq(posTransactions.id, transactionId),
32
+ eq(posTransactions.organizationId, orgId),
33
+ ));
34
+
35
+ if (txns.length === 0) return Err("Transaction not found");
36
+ const txn = txns[0]!;
37
+ if (txn.status !== "open") return Err("Transaction is not open");
38
+
39
+ const rows = await this.db
40
+ .insert(posPayments)
41
+ .values({
42
+ transactionId,
43
+ method: input.method,
44
+ amount: input.amount,
45
+ changeGiven: input.changeGiven ?? 0,
46
+ reference: input.reference,
47
+ status: "collected",
48
+ processedAt: new Date(),
49
+ metadata: input.metadata ?? {},
50
+ })
51
+ .returning();
52
+
53
+ return Ok(rows[0]!);
54
+ }
55
+
56
+ /**
57
+ * Get total payments collected for a transaction.
58
+ */
59
+ async getPaymentTotal(transactionId: string): Promise<number> {
60
+ const rows = await this.db
61
+ .select({
62
+ total: sql<number>`COALESCE(SUM(${posPayments.amount} - ${posPayments.changeGiven}), 0)`.as("total"),
63
+ })
64
+ .from(posPayments)
65
+ .where(and(
66
+ eq(posPayments.transactionId, transactionId),
67
+ eq(posPayments.status, "collected"),
68
+ ));
69
+
70
+ return Number(rows[0]?.total ?? 0);
71
+ }
72
+
73
+ /**
74
+ * List all payments for a transaction.
75
+ */
76
+ async listPayments(transactionId: string): Promise<PluginResult<Payment[]>> {
77
+ const rows = await this.db
78
+ .select()
79
+ .from(posPayments)
80
+ .where(eq(posPayments.transactionId, transactionId));
81
+
82
+ return Ok(rows);
83
+ }
84
+
85
+ /**
86
+ * Validate that total payments cover the transaction total, then return
87
+ * the transaction details needed for checkout.
88
+ */
89
+ async validateForCompletion(orgId: string, transactionId: string): Promise<PluginResult<{
90
+ transaction: Transaction;
91
+ payments: Payment[];
92
+ totalPaid: number;
93
+ }>> {
94
+ const txns = await this.db
95
+ .select()
96
+ .from(posTransactions)
97
+ .where(and(
98
+ eq(posTransactions.id, transactionId),
99
+ eq(posTransactions.organizationId, orgId),
100
+ ));
101
+
102
+ if (txns.length === 0) return Err("Transaction not found");
103
+ const txn = txns[0]!;
104
+ if (txn.status !== "open") return Err(`Transaction is ${txn.status}, not open`);
105
+
106
+ const payments = await this.db
107
+ .select()
108
+ .from(posPayments)
109
+ .where(and(
110
+ eq(posPayments.transactionId, transactionId),
111
+ eq(posPayments.status, "collected"),
112
+ ));
113
+
114
+ const totalPaid = payments.reduce((sum, p) => sum + p.amount - p.changeGiven, 0);
115
+
116
+ if (txn.total > 0 && totalPaid < txn.total) {
117
+ return Err(`Insufficient payment: ${totalPaid} paid, ${txn.total} required`);
118
+ }
119
+
120
+ return Ok({ transaction: txn, payments, totalPaid });
121
+ }
122
+
123
+ /**
124
+ * Mark payments as refunded for a transaction.
125
+ */
126
+ async refundPayments(transactionId: string, tx?: Db): Promise<void> {
127
+ const db = tx ?? this.db;
128
+ await db
129
+ .update(posPayments)
130
+ .set({ status: "refunded" })
131
+ .where(eq(posPayments.transactionId, transactionId));
132
+ }
133
+ }
@@ -0,0 +1,169 @@
1
+ import { eq, and } from "drizzle-orm";
2
+ import { Ok, Err } from "@unifiedcommerce/core";
3
+ import type { PluginResult } from "@unifiedcommerce/core";
4
+ import { posTransactions, posPayments } from "../schema";
5
+ import type { Db, Transaction, Payment } from "../types";
6
+
7
+ export interface ReceiptData {
8
+ receiptNumber: string;
9
+ transactionId: string;
10
+ terminalCode: string;
11
+ operatorName: string;
12
+ timestamp: Date;
13
+ lineItems: Array<{
14
+ title: string;
15
+ quantity: number;
16
+ unitPrice: number;
17
+ totalPrice: number;
18
+ notes?: string | null;
19
+ }>;
20
+ subtotal: number;
21
+ discountTotal: number;
22
+ taxTotal: number;
23
+ total: number;
24
+ payments: Array<{
25
+ method: string;
26
+ amount: number;
27
+ changeGiven: number;
28
+ reference?: string | null;
29
+ }>;
30
+ changeDue: number;
31
+ customerId?: string | null;
32
+ }
33
+
34
+ export class ReceiptService {
35
+ constructor(
36
+ private db: Db,
37
+ private services: Record<string, unknown>,
38
+ ) {}
39
+
40
+ /**
41
+ * Assemble full receipt data for a completed transaction.
42
+ */
43
+ async getReceipt(orgId: string, transactionId: string): Promise<PluginResult<ReceiptData>> {
44
+ // Get transaction
45
+ const txns = await this.db
46
+ .select()
47
+ .from(posTransactions)
48
+ .where(and(
49
+ eq(posTransactions.id, transactionId),
50
+ eq(posTransactions.organizationId, orgId),
51
+ ));
52
+
53
+ if (txns.length === 0) return Err("Transaction not found");
54
+ const txn = txns[0]!;
55
+
56
+ if (txn.status !== "completed" && txn.status !== "voided") {
57
+ return Err("Receipt is only available for completed or voided transactions");
58
+ }
59
+
60
+ // Get terminal code
61
+ const { posTerminals } = await import("../schema");
62
+ const terminals = await this.db
63
+ .select({ code: posTerminals.code, name: posTerminals.name })
64
+ .from(posTerminals)
65
+ .where(eq(posTerminals.id, txn.terminalId));
66
+
67
+ const terminalCode = terminals[0]?.code ?? "POS";
68
+
69
+ // Get payments
70
+ const payments = await this.db
71
+ .select()
72
+ .from(posPayments)
73
+ .where(eq(posPayments.transactionId, transactionId));
74
+
75
+ // Get order line items from core
76
+ let lineItems: ReceiptData["lineItems"] = [];
77
+ if (txn.orderId) {
78
+ const orders = this.services.orders as {
79
+ getById: (id: string, actor: unknown) => Promise<{ ok: boolean; value?: { lineItems?: Array<{ title?: string; quantity: number; unitPrice?: number; totalPrice?: number }> } }>;
80
+ } | undefined;
81
+
82
+ if (orders) {
83
+ const orderResult = await orders.getById(txn.orderId, null);
84
+ if (orderResult.ok && orderResult.value?.lineItems) {
85
+ lineItems = orderResult.value.lineItems.map((li) => ({
86
+ title: li.title ?? "Item",
87
+ quantity: li.quantity,
88
+ unitPrice: li.unitPrice ?? 0,
89
+ totalPrice: li.totalPrice ?? 0,
90
+ }));
91
+ }
92
+ }
93
+ }
94
+
95
+ // Calculate change due
96
+ const totalPaid = payments
97
+ .filter((p) => p.status === "collected")
98
+ .reduce((sum, p) => sum + p.amount, 0);
99
+ const changeDue = payments
100
+ .filter((p) => p.status === "collected")
101
+ .reduce((sum, p) => sum + p.changeGiven, 0);
102
+
103
+ return Ok({
104
+ receiptNumber: txn.receiptNumber,
105
+ transactionId: txn.id,
106
+ terminalCode,
107
+ operatorName: txn.operatorId,
108
+ timestamp: txn.completedAt ?? txn.createdAt,
109
+ lineItems,
110
+ subtotal: txn.subtotal,
111
+ discountTotal: txn.discountTotal,
112
+ taxTotal: txn.taxTotal,
113
+ total: txn.total,
114
+ payments: payments.map((p) => ({
115
+ method: p.method,
116
+ amount: p.amount,
117
+ changeGiven: p.changeGiven,
118
+ reference: p.reference,
119
+ })),
120
+ changeDue,
121
+ customerId: txn.customerId,
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Send receipt via email (delegates to email service).
127
+ */
128
+ async emailReceipt(orgId: string, transactionId: string, email: string): Promise<PluginResult<{ sent: boolean }>> {
129
+ const receiptResult = await this.getReceipt(orgId, transactionId);
130
+ if (!receiptResult.ok) return receiptResult;
131
+
132
+ const emailService = this.services.email as {
133
+ send?: (opts: { to: string; subject: string; html: string }) => Promise<void>;
134
+ } | undefined;
135
+
136
+ if (!emailService?.send) {
137
+ return Err("Email service not configured");
138
+ }
139
+
140
+ await emailService.send({
141
+ to: email,
142
+ subject: `Receipt ${receiptResult.value.receiptNumber}`,
143
+ html: this.formatReceiptHtml(receiptResult.value),
144
+ });
145
+
146
+ return Ok({ sent: true });
147
+ }
148
+
149
+ private formatReceiptHtml(receipt: ReceiptData): string {
150
+ const lines = receipt.lineItems
151
+ .map((li) => `<tr><td>${li.title}</td><td>${li.quantity}</td><td>${(li.unitPrice / 100).toFixed(2)}</td><td>${(li.totalPrice / 100).toFixed(2)}</td></tr>`)
152
+ .join("");
153
+
154
+ return `
155
+ <h2>Receipt ${receipt.receiptNumber}</h2>
156
+ <p>Date: ${receipt.timestamp.toISOString()}</p>
157
+ <table>
158
+ <tr><th>Item</th><th>Qty</th><th>Price</th><th>Total</th></tr>
159
+ ${lines}
160
+ </table>
161
+ <p>Subtotal: ${(receipt.subtotal / 100).toFixed(2)}</p>
162
+ ${receipt.discountTotal > 0 ? `<p>Discount: -${(receipt.discountTotal / 100).toFixed(2)}</p>` : ""}
163
+ <p>Tax: ${(receipt.taxTotal / 100).toFixed(2)}</p>
164
+ <p><strong>Total: ${(receipt.total / 100).toFixed(2)}</strong></p>
165
+ ${receipt.payments.map((p) => `<p>${p.method}: ${(p.amount / 100).toFixed(2)}</p>`).join("")}
166
+ ${receipt.changeDue > 0 ? `<p>Change: ${(receipt.changeDue / 100).toFixed(2)}</p>` : ""}
167
+ `;
168
+ }
169
+ }
@@ -0,0 +1,65 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { Ok, Err } from "@unifiedcommerce/core";
3
+ import type { PluginResult } from "@unifiedcommerce/core";
4
+ import { posReturnItems } from "../schema";
5
+ import type { Db, ReturnItem } from "../types";
6
+
7
+ export class ReturnService {
8
+ constructor(private db: Db) {}
9
+
10
+ /**
11
+ * Record return items linking back to an original order's line items.
12
+ * Called after a return transaction is created.
13
+ */
14
+ async addReturnItems(transactionId: string, items: Array<{
15
+ originalOrderId: string;
16
+ originalLineItemId: string;
17
+ quantity: number;
18
+ reason: "defective" | "wrong_item" | "changed_mind" | "other";
19
+ restockingFee?: number;
20
+ refundAmount: number;
21
+ }>): Promise<PluginResult<ReturnItem[]>> {
22
+ if (items.length === 0) return Err("At least one item is required");
23
+
24
+ const values = items.map((item) => ({
25
+ transactionId,
26
+ originalOrderId: item.originalOrderId,
27
+ originalLineItemId: item.originalLineItemId,
28
+ quantity: item.quantity,
29
+ reason: item.reason,
30
+ restockingFee: item.restockingFee ?? 0,
31
+ refundAmount: item.refundAmount,
32
+ }));
33
+
34
+ const rows = await this.db
35
+ .insert(posReturnItems)
36
+ .values(values)
37
+ .returning();
38
+
39
+ return Ok(rows);
40
+ }
41
+
42
+ /**
43
+ * Get all return items for a return transaction.
44
+ */
45
+ async getReturnItems(transactionId: string): Promise<PluginResult<ReturnItem[]>> {
46
+ const rows = await this.db
47
+ .select()
48
+ .from(posReturnItems)
49
+ .where(eq(posReturnItems.transactionId, transactionId));
50
+
51
+ return Ok(rows);
52
+ }
53
+
54
+ /**
55
+ * Calculate total refund amount for a return transaction.
56
+ */
57
+ async calculateRefundTotal(transactionId: string): Promise<number> {
58
+ const items = await this.db
59
+ .select()
60
+ .from(posReturnItems)
61
+ .where(eq(posReturnItems.transactionId, transactionId));
62
+
63
+ return items.reduce((sum, item) => sum + item.refundAmount, 0);
64
+ }
65
+ }
@@ -0,0 +1,260 @@
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 { posShifts, posCashEvents, posPayments, posTransactions } from "../schema";
5
+ import type { Db, Shift, CashEvent } from "../types";
6
+
7
+ export class ShiftService {
8
+ constructor(
9
+ private db: Db,
10
+ private transaction: (fn: (tx: Db) => Promise<unknown>) => Promise<unknown>,
11
+ ) {}
12
+
13
+ async open(orgId: string, input: {
14
+ terminalId: string;
15
+ operatorId: string;
16
+ openingFloat: number;
17
+ }): Promise<PluginResult<Shift>> {
18
+ if (input.openingFloat < 0) return Err("Opening float must be non-negative");
19
+
20
+ // Check no open shift on this terminal
21
+ const openShifts = await this.db
22
+ .select()
23
+ .from(posShifts)
24
+ .where(and(
25
+ eq(posShifts.terminalId, input.terminalId),
26
+ eq(posShifts.status, "open"),
27
+ ));
28
+
29
+ if (openShifts.length > 0) {
30
+ return Err("Terminal already has an open shift");
31
+ }
32
+
33
+ const rows = await this.db
34
+ .insert(posShifts)
35
+ .values({
36
+ organizationId: orgId,
37
+ terminalId: input.terminalId,
38
+ operatorId: input.operatorId,
39
+ openingFloat: input.openingFloat,
40
+ status: "open",
41
+ })
42
+ .returning();
43
+
44
+ const shift = rows[0]!;
45
+
46
+ // Record the opening float as a cash event
47
+ await this.db.insert(posCashEvents).values({
48
+ shiftId: shift.id,
49
+ type: "float",
50
+ amount: input.openingFloat,
51
+ performedBy: input.operatorId,
52
+ performedAt: new Date(),
53
+ });
54
+
55
+ return Ok(shift);
56
+ }
57
+
58
+ async close(orgId: string, shiftId: string, input: {
59
+ closingCount: number;
60
+ }): Promise<PluginResult<Shift>> {
61
+ const result = await this.transaction(async (tx) => {
62
+ const shifts = await tx
63
+ .select()
64
+ .from(posShifts)
65
+ .where(and(eq(posShifts.id, shiftId), eq(posShifts.organizationId, orgId)))
66
+ .for("update");
67
+
68
+ if (shifts.length === 0) return Err("Shift not found");
69
+ const shift = shifts[0]!;
70
+ if (shift.status === "closed") return Err("Shift is already closed");
71
+
72
+ // Calculate expected cash
73
+ const expectedCash = await this.calculateExpectedCash(tx, shiftId, shift.openingFloat);
74
+ const cashVariance = input.closingCount - expectedCash;
75
+
76
+ const updated = await tx
77
+ .update(posShifts)
78
+ .set({
79
+ status: "closed",
80
+ closingCount: input.closingCount,
81
+ expectedCash,
82
+ cashVariance,
83
+ closedAt: new Date(),
84
+ updatedAt: new Date(),
85
+ })
86
+ .where(eq(posShifts.id, shiftId))
87
+ .returning();
88
+
89
+ return Ok(updated[0]!);
90
+ });
91
+
92
+ return result as PluginResult<Shift>;
93
+ }
94
+
95
+ async getCurrent(orgId: string, operatorId: string): Promise<PluginResult<Shift | null>> {
96
+ const rows = await this.db
97
+ .select()
98
+ .from(posShifts)
99
+ .where(and(
100
+ eq(posShifts.organizationId, orgId),
101
+ eq(posShifts.operatorId, operatorId),
102
+ eq(posShifts.status, "open"),
103
+ ));
104
+
105
+ return Ok(rows[0] ?? null);
106
+ }
107
+
108
+ async getById(orgId: string, id: string): Promise<PluginResult<Shift>> {
109
+ const rows = await this.db
110
+ .select()
111
+ .from(posShifts)
112
+ .where(and(eq(posShifts.id, id), eq(posShifts.organizationId, orgId)));
113
+
114
+ if (rows.length === 0) return Err("Shift not found");
115
+ return Ok(rows[0]!);
116
+ }
117
+
118
+ // ─── Cash Events ───────────────────────────────────────────────────
119
+
120
+ async addCashEvent(shiftId: string, input: {
121
+ type: "drop" | "pickup" | "paid_in" | "paid_out";
122
+ amount: number;
123
+ reason?: string;
124
+ performedBy: string;
125
+ }): Promise<PluginResult<CashEvent>> {
126
+ if (input.amount <= 0) return Err("Amount must be positive");
127
+
128
+ // Verify shift is open
129
+ const shifts = await this.db
130
+ .select()
131
+ .from(posShifts)
132
+ .where(eq(posShifts.id, shiftId));
133
+
134
+ if (shifts.length === 0) return Err("Shift not found");
135
+ if (shifts[0]!.status !== "open") return Err("Shift is not open");
136
+
137
+ const rows = await this.db
138
+ .insert(posCashEvents)
139
+ .values({
140
+ shiftId,
141
+ type: input.type,
142
+ amount: input.amount,
143
+ reason: input.reason,
144
+ performedBy: input.performedBy,
145
+ performedAt: new Date(),
146
+ })
147
+ .returning();
148
+
149
+ return Ok(rows[0]!);
150
+ }
151
+
152
+ async listCashEvents(shiftId: string): Promise<PluginResult<CashEvent[]>> {
153
+ const rows = await this.db
154
+ .select()
155
+ .from(posCashEvents)
156
+ .where(eq(posCashEvents.shiftId, shiftId))
157
+ .orderBy(desc(posCashEvents.performedAt));
158
+ return Ok(rows);
159
+ }
160
+
161
+ // ─── Z-Report ──────────────────────────────────────────────────────
162
+
163
+ async getReport(orgId: string, shiftId: string): Promise<PluginResult<{
164
+ shift: Shift;
165
+ cashEvents: CashEvent[];
166
+ paymentMethodTotals: Array<{ method: string; total: number; count: number }>;
167
+ transactionCount: number;
168
+ }>> {
169
+ const shiftResult = await this.getById(orgId, shiftId);
170
+ if (!shiftResult.ok) return shiftResult;
171
+ const shift = shiftResult.value;
172
+
173
+ const cashEvents = await this.db
174
+ .select()
175
+ .from(posCashEvents)
176
+ .where(eq(posCashEvents.shiftId, shiftId))
177
+ .orderBy(desc(posCashEvents.performedAt));
178
+
179
+ // Payment method totals
180
+ const paymentRows = await this.db
181
+ .select({
182
+ method: posPayments.method,
183
+ total: sql<number>`SUM(${posPayments.amount})`.as("total"),
184
+ count: sql<number>`COUNT(*)`.as("count"),
185
+ })
186
+ .from(posPayments)
187
+ .innerJoin(posTransactions, eq(posPayments.transactionId, posTransactions.id))
188
+ .where(and(
189
+ eq(posTransactions.shiftId, shiftId),
190
+ eq(posTransactions.status, "completed"),
191
+ ))
192
+ .groupBy(posPayments.method);
193
+
194
+ const transactionCountRows = await this.db
195
+ .select({ count: sql<number>`COUNT(*)`.as("count") })
196
+ .from(posTransactions)
197
+ .where(and(
198
+ eq(posTransactions.shiftId, shiftId),
199
+ eq(posTransactions.status, "completed"),
200
+ ));
201
+
202
+ return Ok({
203
+ shift,
204
+ cashEvents,
205
+ paymentMethodTotals: paymentRows.map((r) => ({
206
+ method: r.method,
207
+ total: Number(r.total),
208
+ count: Number(r.count),
209
+ })),
210
+ transactionCount: Number(transactionCountRows[0]?.count ?? 0),
211
+ });
212
+ }
213
+
214
+ // ─── Helpers ───────────────────────────────────────────────────────
215
+
216
+ private async calculateExpectedCash(db: Db, shiftId: string, openingFloat: number): Promise<number> {
217
+ // Cash payments collected during this shift
218
+ const cashPaymentRows = await db
219
+ .select({
220
+ total: sql<number>`COALESCE(SUM(${posPayments.amount} - ${posPayments.changeGiven}), 0)`.as("total"),
221
+ })
222
+ .from(posPayments)
223
+ .innerJoin(posTransactions, eq(posPayments.transactionId, posTransactions.id))
224
+ .where(and(
225
+ eq(posTransactions.shiftId, shiftId),
226
+ eq(posPayments.method, "cash"),
227
+ eq(posPayments.status, "collected"),
228
+ ));
229
+
230
+ const cashFromSales = Number(cashPaymentRows[0]?.total ?? 0);
231
+
232
+ // Cash events: drops reduce, pickups add to drawer
233
+ const cashEventRows = await db
234
+ .select({
235
+ type: posCashEvents.type,
236
+ total: sql<number>`SUM(${posCashEvents.amount})`.as("total"),
237
+ })
238
+ .from(posCashEvents)
239
+ .where(eq(posCashEvents.shiftId, shiftId))
240
+ .groupBy(posCashEvents.type);
241
+
242
+ let cashAdjustment = 0;
243
+ for (const row of cashEventRows) {
244
+ const amount = Number(row.total);
245
+ switch (row.type) {
246
+ case "drop":
247
+ case "paid_out":
248
+ cashAdjustment -= amount;
249
+ break;
250
+ case "pickup":
251
+ case "paid_in":
252
+ cashAdjustment += amount;
253
+ break;
254
+ // float is already accounted for in openingFloat
255
+ }
256
+ }
257
+
258
+ return openingFloat + cashFromSales + cashAdjustment;
259
+ }
260
+ }
@@ -0,0 +1,76 @@
1
+ import { eq, and } from "drizzle-orm";
2
+ import { Ok, Err } from "@unifiedcommerce/core";
3
+ import type { PluginResult } from "@unifiedcommerce/core";
4
+ import { posTerminals } from "../schema";
5
+ import type { Db, Terminal, TerminalInsert } from "../types";
6
+
7
+ export class TerminalService {
8
+ constructor(private db: Db) {}
9
+
10
+ async create(orgId: string, input: {
11
+ name: string;
12
+ code: string;
13
+ type?: "register" | "tablet" | "mobile" | "kiosk";
14
+ metadata?: Record<string, unknown>;
15
+ }): Promise<PluginResult<Terminal>> {
16
+ // Check for duplicate code in same org
17
+ const existing = await this.db
18
+ .select()
19
+ .from(posTerminals)
20
+ .where(and(eq(posTerminals.organizationId, orgId), eq(posTerminals.code, input.code)));
21
+
22
+ if (existing.length > 0) {
23
+ return Err(`Terminal with code '${input.code}' already exists`);
24
+ }
25
+
26
+ const rows = await this.db
27
+ .insert(posTerminals)
28
+ .values({
29
+ organizationId: orgId,
30
+ name: input.name,
31
+ code: input.code,
32
+ type: input.type ?? "register",
33
+ metadata: input.metadata ?? {},
34
+ } as TerminalInsert)
35
+ .returning();
36
+
37
+ return Ok(rows[0]!);
38
+ }
39
+
40
+ async list(orgId: string): Promise<PluginResult<Terminal[]>> {
41
+ const rows = await this.db
42
+ .select()
43
+ .from(posTerminals)
44
+ .where(eq(posTerminals.organizationId, orgId));
45
+ return Ok(rows);
46
+ }
47
+
48
+ async getById(orgId: string, id: string): Promise<PluginResult<Terminal>> {
49
+ const rows = await this.db
50
+ .select()
51
+ .from(posTerminals)
52
+ .where(and(eq(posTerminals.id, id), eq(posTerminals.organizationId, orgId)));
53
+
54
+ if (rows.length === 0) return Err("Terminal not found");
55
+ return Ok(rows[0]!);
56
+ }
57
+
58
+ async update(orgId: string, id: string, input: {
59
+ name?: string;
60
+ isActive?: boolean;
61
+ metadata?: Record<string, unknown>;
62
+ }): Promise<PluginResult<Terminal>> {
63
+ const rows = await this.db
64
+ .update(posTerminals)
65
+ .set({ ...input, updatedAt: new Date() })
66
+ .where(and(eq(posTerminals.id, id), eq(posTerminals.organizationId, orgId)))
67
+ .returning();
68
+
69
+ if (rows.length === 0) return Err("Terminal not found");
70
+ return Ok(rows[0]!);
71
+ }
72
+
73
+ async deactivate(orgId: string, id: string): Promise<PluginResult<Terminal>> {
74
+ return this.update(orgId, id, { isActive: false });
75
+ }
76
+ }