@porulle/plugin-pos 0.7.0 → 0.9.0
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.map +1 -1
- package/dist/hooks/checkout-pos.js +9 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -1
- package/dist/routes/auth.d.ts +19 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +66 -0
- package/dist/routes/exchanges.d.ts +15 -0
- package/dist/routes/exchanges.d.ts.map +1 -0
- package/dist/routes/exchanges.js +42 -0
- package/dist/routes/returns.d.ts.map +1 -1
- package/dist/routes/returns.js +25 -4
- package/dist/schema.d.ts +235 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +25 -0
- package/dist/services/exchange-service.d.ts +55 -0
- package/dist/services/exchange-service.d.ts.map +1 -0
- package/dist/services/exchange-service.js +168 -0
- package/dist/services/pin-service.d.ts +73 -0
- package/dist/services/pin-service.d.ts.map +1 -0
- package/dist/services/pin-service.js +269 -0
- package/dist/services/transaction-service.d.ts.map +1 -1
- package/dist/services/transaction-service.js +5 -5
- package/dist/types.d.ts +16 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -0
- package/package.json +3 -3
- package/src/hooks/checkout-pos.ts +9 -2
- package/src/index.ts +33 -0
- package/src/routes/auth.ts +72 -0
- package/src/routes/exchanges.ts +53 -0
- package/src/routes/returns.ts +47 -5
- package/src/schema.ts +29 -0
- package/src/services/exchange-service.ts +272 -0
- package/src/services/pin-service.ts +350 -0
- package/src/services/transaction-service.ts +5 -4
- package/src/types.ts +20 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { eq, and } from "@porulle/core/drizzle";
|
|
2
|
+
import { Ok, Err } from "@porulle/core";
|
|
3
|
+
import type { PluginResult } from "@porulle/core";
|
|
4
|
+
import { user } from "@porulle/core/auth-schema";
|
|
5
|
+
import { posOperatorPins, posPinAttempts, posShifts } from "../schema.js";
|
|
6
|
+
import type { Db, OperatorPin, PinAttempt, Shift } from "../types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* PIN auth runtime (issue #51).
|
|
10
|
+
*
|
|
11
|
+
* PINs hash with PBKDF2-SHA256 via Web Crypto (Workers-safe, no Node-only
|
|
12
|
+
* APIs). PIN login verifies the operator's PIN against their open shift and
|
|
13
|
+
* mints a short-lived Better Auth API key scoped to POS operation — the
|
|
14
|
+
* per-shift credential POS apps previously hand-rolled through module-global
|
|
15
|
+
* auth holders. Manager override verifies a PIN whose record carries
|
|
16
|
+
* `canOverride` and returns an audited approval.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const PBKDF2_ITERATIONS = 100_000;
|
|
20
|
+
const DEFAULT_LOCKOUT_MAX_ATTEMPTS = 5;
|
|
21
|
+
const DEFAULT_LOCKOUT_WINDOW_MINUTES = 15;
|
|
22
|
+
export const PIN_LOCKOUT_ERROR = "Too many failed PIN attempts; try again later";
|
|
23
|
+
|
|
24
|
+
type PinVerifyOutcome =
|
|
25
|
+
| { status: "ok"; record: OperatorPin }
|
|
26
|
+
| { status: "locked" }
|
|
27
|
+
| { status: "invalid" };
|
|
28
|
+
|
|
29
|
+
/** Narrow Better Auth surface the PIN runtime uses. */
|
|
30
|
+
export interface PinAuthApi {
|
|
31
|
+
api: {
|
|
32
|
+
createApiKey(input: { body: Record<string, unknown> }): Promise<unknown>;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toBase64(bytes: Uint8Array): string {
|
|
37
|
+
let binary = "";
|
|
38
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
39
|
+
return btoa(binary);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fromBase64(value: string): Uint8Array {
|
|
43
|
+
const binary = atob(value);
|
|
44
|
+
const bytes = new Uint8Array(binary.length);
|
|
45
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
46
|
+
return bytes;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function deriveHash(pin: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
|
|
50
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
51
|
+
"raw",
|
|
52
|
+
new TextEncoder().encode(pin),
|
|
53
|
+
"PBKDF2",
|
|
54
|
+
false,
|
|
55
|
+
["deriveBits"],
|
|
56
|
+
);
|
|
57
|
+
const bits = await crypto.subtle.deriveBits(
|
|
58
|
+
{ name: "PBKDF2", hash: "SHA-256", salt: salt.slice().buffer as ArrayBuffer, iterations },
|
|
59
|
+
keyMaterial,
|
|
60
|
+
256,
|
|
61
|
+
);
|
|
62
|
+
return new Uint8Array(bits);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function hashPin(pin: string): Promise<string> {
|
|
66
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
67
|
+
const hash = await deriveHash(pin, salt, PBKDF2_ITERATIONS);
|
|
68
|
+
return `pbkdf2$${PBKDF2_ITERATIONS}$${toBase64(salt)}$${toBase64(hash)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function verifyPinHash(pin: string, encoded: string): Promise<boolean> {
|
|
72
|
+
const [scheme, iterationsRaw, saltB64, hashB64] = encoded.split("$");
|
|
73
|
+
if (scheme !== "pbkdf2" || !iterationsRaw || !saltB64 || !hashB64) return false;
|
|
74
|
+
const expected = fromBase64(hashB64);
|
|
75
|
+
const actual = await deriveHash(pin, fromBase64(saltB64), Number(iterationsRaw));
|
|
76
|
+
if (actual.length !== expected.length) return false;
|
|
77
|
+
let diff = 0;
|
|
78
|
+
for (let i = 0; i < actual.length; i++) diff |= actual[i]! ^ expected[i]!;
|
|
79
|
+
return diff === 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function ensureOperatorUser(db: Db, operatorId: string): Promise<void> {
|
|
83
|
+
// SEC-16/R-01: ensure ONLY a user row exists so a user-referenced API key can
|
|
84
|
+
// be minted. We create NO org membership and grant NO role — better-auth's
|
|
85
|
+
// org-referenced keys require owner/admin (a cashier->owner escalation), so
|
|
86
|
+
// the key is user-referenced and carries the operator's store in metadata,
|
|
87
|
+
// read back by authMiddleware.
|
|
88
|
+
const users = await db.select().from(user).where(eq(user.id, operatorId));
|
|
89
|
+
if (users.length === 0) {
|
|
90
|
+
await db.insert(user).values({
|
|
91
|
+
id: operatorId,
|
|
92
|
+
name: operatorId,
|
|
93
|
+
email: `${operatorId}@pos.local`,
|
|
94
|
+
emailVerified: true,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class PinService {
|
|
100
|
+
constructor(
|
|
101
|
+
private db: Db,
|
|
102
|
+
private options: {
|
|
103
|
+
/** Named auth.apiKeyScopes config used to mint shift keys (optional). */
|
|
104
|
+
apiKeyScope?: string | undefined;
|
|
105
|
+
/** Shift-credential lifetime in seconds. Default: 12h. */
|
|
106
|
+
credentialTtlSeconds?: number | undefined;
|
|
107
|
+
/** Failed PIN attempts before lockout (SEC-15). Default: 5. */
|
|
108
|
+
lockoutMaxAttempts?: number | undefined;
|
|
109
|
+
/** Failure window and lockout duration in minutes (SEC-15). Default: 15. */
|
|
110
|
+
lockoutWindowMinutes?: number | undefined;
|
|
111
|
+
} = {},
|
|
112
|
+
) {}
|
|
113
|
+
|
|
114
|
+
private lockoutMaxAttempts(): number {
|
|
115
|
+
return this.options.lockoutMaxAttempts ?? DEFAULT_LOCKOUT_MAX_ATTEMPTS;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private lockoutWindowMs(): number {
|
|
119
|
+
const minutes = this.options.lockoutWindowMinutes ?? DEFAULT_LOCKOUT_WINDOW_MINUTES;
|
|
120
|
+
return minutes * 60 * 1000;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async getPinAttempt(
|
|
124
|
+
orgId: string,
|
|
125
|
+
operatorId: string,
|
|
126
|
+
): Promise<PinAttempt | undefined> {
|
|
127
|
+
const rows = await this.db
|
|
128
|
+
.select()
|
|
129
|
+
.from(posPinAttempts)
|
|
130
|
+
.where(and(
|
|
131
|
+
eq(posPinAttempts.organizationId, orgId),
|
|
132
|
+
eq(posPinAttempts.operatorId, operatorId),
|
|
133
|
+
));
|
|
134
|
+
return rows[0] as PinAttempt | undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private isLockedOut(attempt: PinAttempt | undefined, now: Date): boolean {
|
|
138
|
+
return attempt?.lockedUntil != null && attempt.lockedUntil > now;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async recordFailedPinAttempt(orgId: string, operatorId: string): Promise<void> {
|
|
142
|
+
const now = new Date();
|
|
143
|
+
const windowMs = this.lockoutWindowMs();
|
|
144
|
+
const maxAttempts = this.lockoutMaxAttempts();
|
|
145
|
+
const existing = await this.getPinAttempt(orgId, operatorId);
|
|
146
|
+
|
|
147
|
+
let failedCount = 1;
|
|
148
|
+
if (existing) {
|
|
149
|
+
const windowExpired = now.getTime() - existing.updatedAt.getTime() > windowMs;
|
|
150
|
+
failedCount = windowExpired ? 1 : existing.failedCount + 1;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const lockedUntil = failedCount >= maxAttempts
|
|
154
|
+
? new Date(now.getTime() + windowMs)
|
|
155
|
+
: null;
|
|
156
|
+
|
|
157
|
+
if (existing) {
|
|
158
|
+
await this.db
|
|
159
|
+
.update(posPinAttempts)
|
|
160
|
+
.set({ failedCount, lockedUntil, updatedAt: now })
|
|
161
|
+
.where(eq(posPinAttempts.id, existing.id));
|
|
162
|
+
} else {
|
|
163
|
+
await this.db.insert(posPinAttempts).values({
|
|
164
|
+
organizationId: orgId,
|
|
165
|
+
operatorId,
|
|
166
|
+
failedCount,
|
|
167
|
+
lockedUntil,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async clearPinAttempts(orgId: string, operatorId: string): Promise<void> {
|
|
173
|
+
const existing = await this.getPinAttempt(orgId, operatorId);
|
|
174
|
+
if (!existing) return;
|
|
175
|
+
await this.db
|
|
176
|
+
.update(posPinAttempts)
|
|
177
|
+
.set({ failedCount: 0, lockedUntil: null, updatedAt: new Date() })
|
|
178
|
+
.where(eq(posPinAttempts.id, existing.id));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async setPin(
|
|
182
|
+
orgId: string,
|
|
183
|
+
input: { operatorId: string; pin: string; canOverride?: boolean | undefined },
|
|
184
|
+
): Promise<PluginResult<{ operatorId: string; canOverride: boolean }>> {
|
|
185
|
+
if (!/^\d{4,8}$/.test(input.pin)) {
|
|
186
|
+
return Err("PIN must be 4-8 digits");
|
|
187
|
+
}
|
|
188
|
+
const pinHash = await hashPin(input.pin);
|
|
189
|
+
const existing = await this.db
|
|
190
|
+
.select()
|
|
191
|
+
.from(posOperatorPins)
|
|
192
|
+
.where(and(
|
|
193
|
+
eq(posOperatorPins.organizationId, orgId),
|
|
194
|
+
eq(posOperatorPins.operatorId, input.operatorId),
|
|
195
|
+
));
|
|
196
|
+
if (existing.length > 0) {
|
|
197
|
+
await this.db
|
|
198
|
+
.update(posOperatorPins)
|
|
199
|
+
.set({
|
|
200
|
+
pinHash,
|
|
201
|
+
canOverride: input.canOverride ?? (existing[0] as OperatorPin).canOverride,
|
|
202
|
+
updatedAt: new Date(),
|
|
203
|
+
})
|
|
204
|
+
.where(eq(posOperatorPins.id, (existing[0] as OperatorPin).id));
|
|
205
|
+
} else {
|
|
206
|
+
await this.db.insert(posOperatorPins).values({
|
|
207
|
+
organizationId: orgId,
|
|
208
|
+
operatorId: input.operatorId,
|
|
209
|
+
pinHash,
|
|
210
|
+
canOverride: input.canOverride ?? false,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return Ok({ operatorId: input.operatorId, canOverride: input.canOverride ?? false });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private async verifyOperatorPin(
|
|
217
|
+
orgId: string,
|
|
218
|
+
operatorId: string,
|
|
219
|
+
pin: string,
|
|
220
|
+
): Promise<PinVerifyOutcome> {
|
|
221
|
+
const now = new Date();
|
|
222
|
+
const attempt = await this.getPinAttempt(orgId, operatorId);
|
|
223
|
+
if (this.isLockedOut(attempt, now)) return { status: "locked" };
|
|
224
|
+
|
|
225
|
+
const rows = await this.db
|
|
226
|
+
.select()
|
|
227
|
+
.from(posOperatorPins)
|
|
228
|
+
.where(and(
|
|
229
|
+
eq(posOperatorPins.organizationId, orgId),
|
|
230
|
+
eq(posOperatorPins.operatorId, operatorId),
|
|
231
|
+
));
|
|
232
|
+
const record = rows[0] as OperatorPin | undefined;
|
|
233
|
+
if (!record) {
|
|
234
|
+
await this.recordFailedPinAttempt(orgId, operatorId);
|
|
235
|
+
return { status: "invalid" };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!(await verifyPinHash(pin, record.pinHash))) {
|
|
239
|
+
await this.recordFailedPinAttempt(orgId, operatorId);
|
|
240
|
+
return { status: "invalid" };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await this.clearPinAttempts(orgId, operatorId);
|
|
244
|
+
return { status: "ok", record };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* PIN login: verifies the PIN, resolves the operator's open shift, and
|
|
249
|
+
* mints a short-lived API key bound to it. The terminal's device key
|
|
250
|
+
* authenticates this call (pos:operate); the returned key is personal.
|
|
251
|
+
*/
|
|
252
|
+
async pinLogin(
|
|
253
|
+
orgId: string,
|
|
254
|
+
input: { operatorId: string; pin: string; shiftId?: string | undefined },
|
|
255
|
+
auth: PinAuthApi | undefined,
|
|
256
|
+
): Promise<PluginResult<{
|
|
257
|
+
operatorId: string;
|
|
258
|
+
shiftId: string;
|
|
259
|
+
terminalId: string;
|
|
260
|
+
apiKey: string;
|
|
261
|
+
expiresAt: string;
|
|
262
|
+
}>> {
|
|
263
|
+
const verified = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
|
|
264
|
+
if (verified.status === "locked") return Err(PIN_LOCKOUT_ERROR);
|
|
265
|
+
if (verified.status !== "ok") return Err("Invalid operator or PIN");
|
|
266
|
+
|
|
267
|
+
const conditions = [
|
|
268
|
+
eq(posShifts.organizationId, orgId),
|
|
269
|
+
eq(posShifts.operatorId, input.operatorId),
|
|
270
|
+
eq(posShifts.status, "open"),
|
|
271
|
+
];
|
|
272
|
+
if (input.shiftId) conditions.push(eq(posShifts.id, input.shiftId));
|
|
273
|
+
const shifts = await this.db.select().from(posShifts).where(and(...conditions));
|
|
274
|
+
const shift = shifts[0] as Shift | undefined;
|
|
275
|
+
if (!shift) return Err("No open shift for this operator — open a shift first");
|
|
276
|
+
|
|
277
|
+
if (!auth) {
|
|
278
|
+
return Err("PIN login requires the Better Auth instance (plugin ctx.auth) — are routes mounted by createServer?");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
await ensureOperatorUser(this.db, input.operatorId);
|
|
282
|
+
|
|
283
|
+
const ttlSeconds = this.options.credentialTtlSeconds ?? 12 * 3600;
|
|
284
|
+
let created: { key?: string; id?: string };
|
|
285
|
+
try {
|
|
286
|
+
created = (await auth.api.createApiKey({
|
|
287
|
+
body: {
|
|
288
|
+
// Better Auth caps name length at 32; the full shift id lives on the
|
|
289
|
+
// shift row (metadata.pinLoginApiKeyId links back).
|
|
290
|
+
name: `pos-shift-${shift.id.slice(0, 8)}`,
|
|
291
|
+
userId: input.operatorId,
|
|
292
|
+
organizationId: orgId,
|
|
293
|
+
expiresIn: ttlSeconds,
|
|
294
|
+
permissions: { pos: ["operate"] },
|
|
295
|
+
metadata: { operatorId: input.operatorId, organizationId: orgId },
|
|
296
|
+
// Shift/terminal binding is recorded on the shift row; the key name
|
|
297
|
+
// carries the shift id for traceability.
|
|
298
|
+
// The plugin registers this scope via its apiKeyScopes manifest.
|
|
299
|
+
configId: this.options.apiKeyScope ?? "pos",
|
|
300
|
+
},
|
|
301
|
+
})) as { key?: string; id?: string };
|
|
302
|
+
} catch (error) {
|
|
303
|
+
const message = error instanceof Error ? error.message : "Failed to mint shift credential";
|
|
304
|
+
return Err(message);
|
|
305
|
+
}
|
|
306
|
+
if (!created?.key) return Err("Failed to mint shift credential");
|
|
307
|
+
|
|
308
|
+
// Record the credential on the shift so admins can trace/revoke it.
|
|
309
|
+
await this.db
|
|
310
|
+
.update(posShifts)
|
|
311
|
+
.set({
|
|
312
|
+
metadata: { ...(shift.metadata ?? {}), pinLoginApiKeyId: created.id ?? null },
|
|
313
|
+
updatedAt: new Date(),
|
|
314
|
+
})
|
|
315
|
+
.where(eq(posShifts.id, shift.id));
|
|
316
|
+
|
|
317
|
+
return Ok({
|
|
318
|
+
operatorId: input.operatorId,
|
|
319
|
+
shiftId: shift.id,
|
|
320
|
+
terminalId: shift.terminalId,
|
|
321
|
+
apiKey: created.key,
|
|
322
|
+
expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Manager override: verifies the manager's PIN (record must carry
|
|
328
|
+
* canOverride) and returns an approval envelope for the requested action.
|
|
329
|
+
*/
|
|
330
|
+
async override(
|
|
331
|
+
orgId: string,
|
|
332
|
+
input: { operatorId: string; pin: string; action: string },
|
|
333
|
+
): Promise<PluginResult<{
|
|
334
|
+
approved: true;
|
|
335
|
+
operatorId: string;
|
|
336
|
+
action: string;
|
|
337
|
+
approvedAt: string;
|
|
338
|
+
}>> {
|
|
339
|
+
const verified = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
|
|
340
|
+
if (verified.status === "locked") return Err(PIN_LOCKOUT_ERROR);
|
|
341
|
+
if (verified.status !== "ok") return Err("Invalid operator or PIN");
|
|
342
|
+
if (!verified.record.canOverride) return Err("This operator cannot approve overrides");
|
|
343
|
+
return Ok({
|
|
344
|
+
approved: true,
|
|
345
|
+
operatorId: input.operatorId,
|
|
346
|
+
action: input.action,
|
|
347
|
+
approvedAt: new Date().toISOString(),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -25,7 +25,7 @@ export class TransactionService {
|
|
|
25
25
|
.where(and(eq(posShifts.id, input.shiftId), eq(posShifts.status, "open")));
|
|
26
26
|
if (shifts.length === 0) return Err("Shift is not open");
|
|
27
27
|
|
|
28
|
-
const receiptNumber = await this.generateReceiptNumber(input.terminalId);
|
|
28
|
+
const receiptNumber = await this.generateReceiptNumber(orgId, input.terminalId);
|
|
29
29
|
|
|
30
30
|
const rows = await this.db
|
|
31
31
|
.insert(posTransactions)
|
|
@@ -121,7 +121,7 @@ export class TransactionService {
|
|
|
121
121
|
await tx
|
|
122
122
|
.update(posShifts)
|
|
123
123
|
.set({ voidsCount: sql`${posShifts.voidsCount} + 1`, updatedAt: new Date() })
|
|
124
|
-
.where(eq(posShifts.id, txn.shiftId));
|
|
124
|
+
.where(and(eq(posShifts.id, txn.shiftId), eq(posShifts.organizationId, orgId)));
|
|
125
125
|
|
|
126
126
|
return Ok(updated[0]!);
|
|
127
127
|
});
|
|
@@ -207,7 +207,7 @@ export class TransactionService {
|
|
|
207
207
|
salesTotal: sql`${posShifts.salesTotal} + ${paymentTotal}`,
|
|
208
208
|
updatedAt: new Date(),
|
|
209
209
|
})
|
|
210
|
-
.where(eq(posShifts.id, txn.shiftId));
|
|
210
|
+
.where(and(eq(posShifts.id, txn.shiftId), eq(posShifts.organizationId, txn.organizationId)));
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
return Ok(txn);
|
|
@@ -219,7 +219,7 @@ export class TransactionService {
|
|
|
219
219
|
// ─── Receipt Number Generation ─────────────────────────────────────
|
|
220
220
|
// Sequential per terminal per day: {terminal_code}-{sequence}
|
|
221
221
|
|
|
222
|
-
private async generateReceiptNumber(terminalId: string): Promise<string> {
|
|
222
|
+
private async generateReceiptNumber(orgId: string, terminalId: string): Promise<string> {
|
|
223
223
|
const today = new Date();
|
|
224
224
|
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
225
225
|
const startOfDayISO = startOfDay.toISOString();
|
|
@@ -238,6 +238,7 @@ export class TransactionService {
|
|
|
238
238
|
.select({ count: sql<number>`COUNT(*)`.as("count") })
|
|
239
239
|
.from(posTransactions)
|
|
240
240
|
.where(and(
|
|
241
|
+
eq(posTransactions.organizationId, orgId),
|
|
241
242
|
eq(posTransactions.terminalId, terminalId),
|
|
242
243
|
sql`${posTransactions.createdAt} >= ${startOfDayISO}::timestamptz`,
|
|
243
244
|
));
|
package/src/types.ts
CHANGED
|
@@ -6,6 +6,8 @@ import type {
|
|
|
6
6
|
posTransactions,
|
|
7
7
|
posPayments,
|
|
8
8
|
posReturnItems,
|
|
9
|
+
posOperatorPins,
|
|
10
|
+
posPinAttempts,
|
|
9
11
|
} from "./schema.js";
|
|
10
12
|
|
|
11
13
|
export type Terminal = typeof posTerminals.$inferSelect;
|
|
@@ -26,6 +28,12 @@ export type PaymentInsert = typeof posPayments.$inferInsert;
|
|
|
26
28
|
export type ReturnItem = typeof posReturnItems.$inferSelect;
|
|
27
29
|
export type ReturnItemInsert = typeof posReturnItems.$inferInsert;
|
|
28
30
|
|
|
31
|
+
export type OperatorPin = typeof posOperatorPins.$inferSelect;
|
|
32
|
+
export type OperatorPinInsert = typeof posOperatorPins.$inferInsert;
|
|
33
|
+
|
|
34
|
+
export type PinAttempt = typeof posPinAttempts.$inferSelect;
|
|
35
|
+
export type PinAttemptInsert = typeof posPinAttempts.$inferInsert;
|
|
36
|
+
|
|
29
37
|
export type TransactionStatus = "open" | "held" | "completed" | "voided";
|
|
30
38
|
export type TransactionType = "sale" | "return" | "exchange";
|
|
31
39
|
export type PaymentMethod = "cash" | "card" | "gift_card" | "store_credit" | "other";
|
|
@@ -40,10 +48,22 @@ export interface POSPluginOptions {
|
|
|
40
48
|
maxHoldHours?: number;
|
|
41
49
|
/** Require manager override for discounts above this percentage. Default: 20 */
|
|
42
50
|
discountOverrideThreshold?: number;
|
|
51
|
+
/** PIN auth runtime (issue #51). */
|
|
52
|
+
pinAuth?: {
|
|
53
|
+
/** Named auth.apiKeyScopes config used to mint per-shift keys. */
|
|
54
|
+
apiKeyScope?: string;
|
|
55
|
+
/** Shift-credential lifetime in seconds. Default: 43200 (12h). */
|
|
56
|
+
credentialTtlSeconds?: number;
|
|
57
|
+
/** Failed PIN attempts before lockout. Default: 5 (SEC-15). */
|
|
58
|
+
lockoutMaxAttempts?: number;
|
|
59
|
+
/** Failure counting window and lockout duration in minutes. Default: 15 (SEC-15). */
|
|
60
|
+
lockoutWindowMinutes?: number;
|
|
61
|
+
};
|
|
43
62
|
}
|
|
44
63
|
|
|
45
64
|
export const DEFAULT_POS_OPTIONS: Required<POSPluginOptions> = {
|
|
46
65
|
defaultCurrency: "USD",
|
|
47
66
|
maxHoldHours: 24,
|
|
48
67
|
discountOverrideThreshold: 20,
|
|
68
|
+
pinAuth: {},
|
|
49
69
|
};
|