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