@porulle/plugin-pos 0.8.0 → 0.10.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.
@@ -1,6 +1,7 @@
1
1
  import { eq, and } from "@porulle/core/drizzle";
2
2
  import { Ok, Err } from "@porulle/core";
3
- import { posOperatorPins, posShifts } from "../schema.js";
3
+ import { user } from "@porulle/core/auth-schema";
4
+ import { posOperatorPins, posPinAttempts, posShifts } from "../schema.js";
4
5
  /**
5
6
  * PIN auth runtime (issue #51).
6
7
  *
@@ -12,6 +13,9 @@ import { posOperatorPins, posShifts } from "../schema.js";
12
13
  * `canOverride` and returns an audited approval.
13
14
  */
14
15
  const PBKDF2_ITERATIONS = 100_000;
16
+ const DEFAULT_LOCKOUT_MAX_ATTEMPTS = 5;
17
+ const DEFAULT_LOCKOUT_WINDOW_MINUTES = 15;
18
+ export const PIN_LOCKOUT_ERROR = "Too many failed PIN attempts; try again later";
15
19
  function toBase64(bytes) {
16
20
  let binary = "";
17
21
  for (const b of bytes)
@@ -48,6 +52,22 @@ export async function verifyPinHash(pin, encoded) {
48
52
  diff |= actual[i] ^ expected[i];
49
53
  return diff === 0;
50
54
  }
55
+ async function ensureOperatorUser(db, operatorId) {
56
+ // SEC-16/R-01: ensure ONLY a user row exists so a user-referenced API key can
57
+ // be minted. We create NO org membership and grant NO role — better-auth's
58
+ // org-referenced keys require owner/admin (a cashier->owner escalation), so
59
+ // the key is user-referenced and carries the operator's store in metadata,
60
+ // read back by authMiddleware.
61
+ const users = await db.select().from(user).where(eq(user.id, operatorId));
62
+ if (users.length === 0) {
63
+ await db.insert(user).values({
64
+ id: operatorId,
65
+ name: operatorId,
66
+ email: `${operatorId}@pos.local`,
67
+ emailVerified: true,
68
+ });
69
+ }
70
+ }
51
71
  export class PinService {
52
72
  db;
53
73
  options;
@@ -55,6 +75,60 @@ export class PinService {
55
75
  this.db = db;
56
76
  this.options = options;
57
77
  }
78
+ lockoutMaxAttempts() {
79
+ return this.options.lockoutMaxAttempts ?? DEFAULT_LOCKOUT_MAX_ATTEMPTS;
80
+ }
81
+ lockoutWindowMs() {
82
+ const minutes = this.options.lockoutWindowMinutes ?? DEFAULT_LOCKOUT_WINDOW_MINUTES;
83
+ return minutes * 60 * 1000;
84
+ }
85
+ async getPinAttempt(orgId, operatorId) {
86
+ const rows = await this.db
87
+ .select()
88
+ .from(posPinAttempts)
89
+ .where(and(eq(posPinAttempts.organizationId, orgId), eq(posPinAttempts.operatorId, operatorId)));
90
+ return rows[0];
91
+ }
92
+ isLockedOut(attempt, now) {
93
+ return attempt?.lockedUntil != null && attempt.lockedUntil > now;
94
+ }
95
+ async recordFailedPinAttempt(orgId, operatorId) {
96
+ const now = new Date();
97
+ const windowMs = this.lockoutWindowMs();
98
+ const maxAttempts = this.lockoutMaxAttempts();
99
+ const existing = await this.getPinAttempt(orgId, operatorId);
100
+ let failedCount = 1;
101
+ if (existing) {
102
+ const windowExpired = now.getTime() - existing.updatedAt.getTime() > windowMs;
103
+ failedCount = windowExpired ? 1 : existing.failedCount + 1;
104
+ }
105
+ const lockedUntil = failedCount >= maxAttempts
106
+ ? new Date(now.getTime() + windowMs)
107
+ : null;
108
+ if (existing) {
109
+ await this.db
110
+ .update(posPinAttempts)
111
+ .set({ failedCount, lockedUntil, updatedAt: now })
112
+ .where(eq(posPinAttempts.id, existing.id));
113
+ }
114
+ else {
115
+ await this.db.insert(posPinAttempts).values({
116
+ organizationId: orgId,
117
+ operatorId,
118
+ failedCount,
119
+ lockedUntil,
120
+ });
121
+ }
122
+ }
123
+ async clearPinAttempts(orgId, operatorId) {
124
+ const existing = await this.getPinAttempt(orgId, operatorId);
125
+ if (!existing)
126
+ return;
127
+ await this.db
128
+ .update(posPinAttempts)
129
+ .set({ failedCount: 0, lockedUntil: null, updatedAt: new Date() })
130
+ .where(eq(posPinAttempts.id, existing.id));
131
+ }
58
132
  async setPin(orgId, input) {
59
133
  if (!/^\d{4,8}$/.test(input.pin)) {
60
134
  return Err("PIN must be 4-8 digits");
@@ -85,14 +159,25 @@ export class PinService {
85
159
  return Ok({ operatorId: input.operatorId, canOverride: input.canOverride ?? false });
86
160
  }
87
161
  async verifyOperatorPin(orgId, operatorId, pin) {
162
+ const now = new Date();
163
+ const attempt = await this.getPinAttempt(orgId, operatorId);
164
+ if (this.isLockedOut(attempt, now))
165
+ return { status: "locked" };
88
166
  const rows = await this.db
89
167
  .select()
90
168
  .from(posOperatorPins)
91
169
  .where(and(eq(posOperatorPins.organizationId, orgId), eq(posOperatorPins.operatorId, operatorId)));
92
170
  const record = rows[0];
93
- if (!record)
94
- return null;
95
- return (await verifyPinHash(pin, record.pinHash)) ? record : null;
171
+ if (!record) {
172
+ await this.recordFailedPinAttempt(orgId, operatorId);
173
+ return { status: "invalid" };
174
+ }
175
+ if (!(await verifyPinHash(pin, record.pinHash))) {
176
+ await this.recordFailedPinAttempt(orgId, operatorId);
177
+ return { status: "invalid" };
178
+ }
179
+ await this.clearPinAttempts(orgId, operatorId);
180
+ return { status: "ok", record };
96
181
  }
97
182
  /**
98
183
  * PIN login: verifies the PIN, resolves the operator's open shift, and
@@ -100,8 +185,10 @@ export class PinService {
100
185
  * authenticates this call (pos:operate); the returned key is personal.
101
186
  */
102
187
  async pinLogin(orgId, input, auth) {
103
- const record = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
104
- if (!record)
188
+ const verified = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
189
+ if (verified.status === "locked")
190
+ return Err(PIN_LOCKOUT_ERROR);
191
+ if (verified.status !== "ok")
105
192
  return Err("Invalid operator or PIN");
106
193
  const conditions = [
107
194
  eq(posShifts.organizationId, orgId),
@@ -117,22 +204,31 @@ export class PinService {
117
204
  if (!auth) {
118
205
  return Err("PIN login requires the Better Auth instance (plugin ctx.auth) — are routes mounted by createServer?");
119
206
  }
207
+ await ensureOperatorUser(this.db, input.operatorId);
120
208
  const ttlSeconds = this.options.credentialTtlSeconds ?? 12 * 3600;
121
- const created = (await auth.api.createApiKey({
122
- body: {
123
- // Better Auth caps name length at 32; the full shift id lives on the
124
- // shift row (metadata.pinLoginApiKeyId links back).
125
- name: `pos-shift-${shift.id.slice(0, 8)}`,
126
- userId: input.operatorId,
127
- expiresIn: ttlSeconds,
128
- permissions: { pos: ["operate"] },
129
- // Shift/terminal binding is recorded on the shift row (metadata is
130
- // disabled by default in Better Auth's apiKey plugin); the key name
131
- // carries the shift id for traceability.
132
- // The plugin registers this scope via its apiKeyScopes manifest.
133
- configId: this.options.apiKeyScope ?? "pos",
134
- },
135
- }));
209
+ let created;
210
+ try {
211
+ created = (await auth.api.createApiKey({
212
+ body: {
213
+ // Better Auth caps name length at 32; the full shift id lives on the
214
+ // shift row (metadata.pinLoginApiKeyId links back).
215
+ name: `pos-shift-${shift.id.slice(0, 8)}`,
216
+ userId: input.operatorId,
217
+ organizationId: orgId,
218
+ expiresIn: ttlSeconds,
219
+ permissions: { pos: ["operate"] },
220
+ metadata: { operatorId: input.operatorId, organizationId: orgId },
221
+ // Shift/terminal binding is recorded on the shift row; the key name
222
+ // carries the shift id for traceability.
223
+ // The plugin registers this scope via its apiKeyScopes manifest.
224
+ configId: this.options.apiKeyScope ?? "pos",
225
+ },
226
+ }));
227
+ }
228
+ catch (error) {
229
+ const message = error instanceof Error ? error.message : "Failed to mint shift credential";
230
+ return Err(message);
231
+ }
136
232
  if (!created?.key)
137
233
  return Err("Failed to mint shift credential");
138
234
  // Record the credential on the shift so admins can trace/revoke it.
@@ -156,10 +252,12 @@ export class PinService {
156
252
  * canOverride) and returns an approval envelope for the requested action.
157
253
  */
158
254
  async override(orgId, input) {
159
- const record = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
160
- if (!record)
255
+ const verified = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
256
+ if (verified.status === "locked")
257
+ return Err(PIN_LOCKOUT_ERROR);
258
+ if (verified.status !== "ok")
161
259
  return Err("Invalid operator or PIN");
162
- if (!record.canOverride)
260
+ if (!verified.record.canOverride)
163
261
  return Err("This operator cannot approve overrides");
164
262
  return Ok({
165
263
  approved: true,
@@ -1 +1 @@
1
- {"version":3,"file":"transaction-service.d.ts","sourceRoot":"","sources":["../../src/services/transaction-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,OAAO,KAAK,EAAE,EAAE,EAAE,WAAW,EAAqB,MAAM,aAAa,CAAC;AAEtE,qBAAa,kBAAkB;IAE3B,OAAO,CAAC,EAAE;IACV,OAAO,CAAC,WAAW;gBADX,EAAE,EAAE,EAAE,EACN,WAAW,EAAE,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC;IAGvE,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE;QACjC,OAAO,EAAE,MAAM,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,UAAU,EAAE,MAAM,CAAC;QACnB,MAAM,EAAE,MAAM,CAAC;QACf,IAAI,CAAC,EAAE,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAC;QACtC,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;IA4BhC,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;IAUtE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;IAclF,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;IAcrE,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,EAAE,CAAC,CAAC;IAcjF,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;IAgCnF,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;IAc9F,YAAY,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE;QACrC,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;QACjB,KAAK,EAAE,MAAM,CAAC;QACd,aAAa,EAAE,MAAM,CAAC;KACvB,GAAG,OAAO,CAAC,IAAI,CAAC;IAOX,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;YAgExE,qBAAqB;CA0BpC"}
1
+ {"version":3,"file":"transaction-service.d.ts","sourceRoot":"","sources":["../../src/services/transaction-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,OAAO,KAAK,EAAE,EAAE,EAAE,WAAW,EAAqB,MAAM,aAAa,CAAC;AAEtE,qBAAa,kBAAkB;IAE3B,OAAO,CAAC,EAAE;IACV,OAAO,CAAC,WAAW;gBADX,EAAE,EAAE,EAAE,EACN,WAAW,EAAE,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC;IAGvE,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE;QACjC,OAAO,EAAE,MAAM,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,UAAU,EAAE,MAAM,CAAC;QACnB,MAAM,EAAE,MAAM,CAAC;QACf,IAAI,CAAC,EAAE,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAC;QACtC,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;IA4BhC,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;IAUtE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;IAclF,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;IAcrE,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,EAAE,CAAC,CAAC;IAcjF,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;IAgCnF,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;IAc9F,YAAY,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE;QACrC,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;QACjB,KAAK,EAAE,MAAM,CAAC;QACd,aAAa,EAAE,MAAM,CAAC;KACvB,GAAG,OAAO,CAAC,IAAI,CAAC;IAOX,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;YAgExE,qBAAqB;CA2BpC"}
@@ -16,7 +16,7 @@ export class TransactionService {
16
16
  .where(and(eq(posShifts.id, input.shiftId), eq(posShifts.status, "open")));
17
17
  if (shifts.length === 0)
18
18
  return Err("Shift is not open");
19
- const receiptNumber = await this.generateReceiptNumber(input.terminalId);
19
+ const receiptNumber = await this.generateReceiptNumber(orgId, input.terminalId);
20
20
  const rows = await this.db
21
21
  .insert(posTransactions)
22
22
  .values({
@@ -99,7 +99,7 @@ export class TransactionService {
99
99
  await tx
100
100
  .update(posShifts)
101
101
  .set({ voidsCount: sql `${posShifts.voidsCount} + 1`, updatedAt: new Date() })
102
- .where(eq(posShifts.id, txn.shiftId));
102
+ .where(and(eq(posShifts.id, txn.shiftId), eq(posShifts.organizationId, orgId)));
103
103
  return Ok(updated[0]);
104
104
  });
105
105
  return result;
@@ -172,7 +172,7 @@ export class TransactionService {
172
172
  salesTotal: sql `${posShifts.salesTotal} + ${paymentTotal}`,
173
173
  updatedAt: new Date(),
174
174
  })
175
- .where(eq(posShifts.id, txn.shiftId));
175
+ .where(and(eq(posShifts.id, txn.shiftId), eq(posShifts.organizationId, txn.organizationId)));
176
176
  }
177
177
  return Ok(txn);
178
178
  });
@@ -180,7 +180,7 @@ export class TransactionService {
180
180
  }
181
181
  // ─── Receipt Number Generation ─────────────────────────────────────
182
182
  // Sequential per terminal per day: {terminal_code}-{sequence}
183
- async generateReceiptNumber(terminalId) {
183
+ async generateReceiptNumber(orgId, terminalId) {
184
184
  const today = new Date();
185
185
  const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
186
186
  const startOfDayISO = startOfDay.toISOString();
@@ -195,7 +195,7 @@ export class TransactionService {
195
195
  const countRows = await this.db
196
196
  .select({ count: sql `COUNT(*)`.as("count") })
197
197
  .from(posTransactions)
198
- .where(and(eq(posTransactions.terminalId, terminalId), sql `${posTransactions.createdAt} >= ${startOfDayISO}::timestamptz`));
198
+ .where(and(eq(posTransactions.organizationId, orgId), eq(posTransactions.terminalId, terminalId), sql `${posTransactions.createdAt} >= ${startOfDayISO}::timestamptz`));
199
199
  const seq = Number(countRows[0]?.count ?? 0) + 1;
200
200
  return `${terminalCode}-${String(seq).padStart(4, "0")}`;
201
201
  }
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type { PluginDb as Db } from "@porulle/core";
2
- import type { posTerminals, posShifts, posCashEvents, posTransactions, posPayments, posReturnItems, posOperatorPins } from "./schema.js";
2
+ import type { posTerminals, posShifts, posCashEvents, posTransactions, posPayments, posReturnItems, posOperatorPins, posPinAttempts } from "./schema.js";
3
3
  export type Terminal = typeof posTerminals.$inferSelect;
4
4
  export type TerminalInsert = typeof posTerminals.$inferInsert;
5
5
  export type Shift = typeof posShifts.$inferSelect;
@@ -14,6 +14,8 @@ export type ReturnItem = typeof posReturnItems.$inferSelect;
14
14
  export type ReturnItemInsert = typeof posReturnItems.$inferInsert;
15
15
  export type OperatorPin = typeof posOperatorPins.$inferSelect;
16
16
  export type OperatorPinInsert = typeof posOperatorPins.$inferInsert;
17
+ export type PinAttempt = typeof posPinAttempts.$inferSelect;
18
+ export type PinAttemptInsert = typeof posPinAttempts.$inferInsert;
17
19
  export type TransactionStatus = "open" | "held" | "completed" | "voided";
18
20
  export type TransactionType = "sale" | "return" | "exchange";
19
21
  export type PaymentMethod = "cash" | "card" | "gift_card" | "store_credit" | "other";
@@ -33,6 +35,10 @@ export interface POSPluginOptions {
33
35
  apiKeyScope?: string;
34
36
  /** Shift-credential lifetime in seconds. Default: 43200 (12h). */
35
37
  credentialTtlSeconds?: number;
38
+ /** Failed PIN attempts before lockout. Default: 5 (SEC-15). */
39
+ lockoutMaxAttempts?: number;
40
+ /** Failure counting window and lockout duration in minutes. Default: 15 (SEC-15). */
41
+ lockoutWindowMinutes?: number;
36
42
  };
37
43
  }
38
44
  export declare const DEFAULT_POS_OPTIONS: Required<POSPluginOptions>;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,KAAK,EACV,YAAY,EACZ,SAAS,EACT,aAAa,EACb,eAAe,EACf,WAAW,EACX,cAAc,EACd,eAAe,EAChB,MAAM,aAAa,CAAC;AAErB,MAAM,MAAM,QAAQ,GAAG,OAAO,YAAY,CAAC,YAAY,CAAC;AACxD,MAAM,MAAM,cAAc,GAAG,OAAO,YAAY,CAAC,YAAY,CAAC;AAE9D,MAAM,MAAM,KAAK,GAAG,OAAO,SAAS,CAAC,YAAY,CAAC;AAClD,MAAM,MAAM,WAAW,GAAG,OAAO,SAAS,CAAC,YAAY,CAAC;AAExD,MAAM,MAAM,SAAS,GAAG,OAAO,aAAa,CAAC,YAAY,CAAC;AAC1D,MAAM,MAAM,eAAe,GAAG,OAAO,aAAa,CAAC,YAAY,CAAC;AAEhE,MAAM,MAAM,WAAW,GAAG,OAAO,eAAe,CAAC,YAAY,CAAC;AAC9D,MAAM,MAAM,iBAAiB,GAAG,OAAO,eAAe,CAAC,YAAY,CAAC;AAEpE,MAAM,MAAM,OAAO,GAAG,OAAO,WAAW,CAAC,YAAY,CAAC;AACtD,MAAM,MAAM,aAAa,GAAG,OAAO,WAAW,CAAC,YAAY,CAAC;AAE5D,MAAM,MAAM,UAAU,GAAG,OAAO,cAAc,CAAC,YAAY,CAAC;AAC5D,MAAM,MAAM,gBAAgB,GAAG,OAAO,cAAc,CAAC,YAAY,CAAC;AAElE,MAAM,MAAM,WAAW,GAAG,OAAO,eAAe,CAAC,YAAY,CAAC;AAC9D,MAAM,MAAM,iBAAiB,GAAG,OAAO,eAAe,CAAC,YAAY,CAAC;AAEpE,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,MAAM,GAAG,WAAW,GAAG,QAAQ,CAAC;AACzE,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAC;AAC7D,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,MAAM,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAC;AACrF,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,GAAG,UAAU,CAAC;AACjF,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,QAAQ,CAAC;AAC5C,MAAM,MAAM,YAAY,GAAG,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEtE,MAAM,WAAW,gBAAgB;IAC/B,4DAA4D;IAC5D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mEAAmE;IACnE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gFAAgF;IAChF,yBAAyB,CAAC,EAAE,MAAM,CAAC;IACnC,oCAAoC;IACpC,OAAO,CAAC,EAAE;QACR,kEAAkE;QAClE,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,kEAAkE;QAClE,oBAAoB,CAAC,EAAE,MAAM,CAAC;KAC/B,CAAC;CACH;AAED,eAAO,MAAM,mBAAmB,EAAE,QAAQ,CAAC,gBAAgB,CAK1D,CAAC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,KAAK,EACV,YAAY,EACZ,SAAS,EACT,aAAa,EACb,eAAe,EACf,WAAW,EACX,cAAc,EACd,eAAe,EACf,cAAc,EACf,MAAM,aAAa,CAAC;AAErB,MAAM,MAAM,QAAQ,GAAG,OAAO,YAAY,CAAC,YAAY,CAAC;AACxD,MAAM,MAAM,cAAc,GAAG,OAAO,YAAY,CAAC,YAAY,CAAC;AAE9D,MAAM,MAAM,KAAK,GAAG,OAAO,SAAS,CAAC,YAAY,CAAC;AAClD,MAAM,MAAM,WAAW,GAAG,OAAO,SAAS,CAAC,YAAY,CAAC;AAExD,MAAM,MAAM,SAAS,GAAG,OAAO,aAAa,CAAC,YAAY,CAAC;AAC1D,MAAM,MAAM,eAAe,GAAG,OAAO,aAAa,CAAC,YAAY,CAAC;AAEhE,MAAM,MAAM,WAAW,GAAG,OAAO,eAAe,CAAC,YAAY,CAAC;AAC9D,MAAM,MAAM,iBAAiB,GAAG,OAAO,eAAe,CAAC,YAAY,CAAC;AAEpE,MAAM,MAAM,OAAO,GAAG,OAAO,WAAW,CAAC,YAAY,CAAC;AACtD,MAAM,MAAM,aAAa,GAAG,OAAO,WAAW,CAAC,YAAY,CAAC;AAE5D,MAAM,MAAM,UAAU,GAAG,OAAO,cAAc,CAAC,YAAY,CAAC;AAC5D,MAAM,MAAM,gBAAgB,GAAG,OAAO,cAAc,CAAC,YAAY,CAAC;AAElE,MAAM,MAAM,WAAW,GAAG,OAAO,eAAe,CAAC,YAAY,CAAC;AAC9D,MAAM,MAAM,iBAAiB,GAAG,OAAO,eAAe,CAAC,YAAY,CAAC;AAEpE,MAAM,MAAM,UAAU,GAAG,OAAO,cAAc,CAAC,YAAY,CAAC;AAC5D,MAAM,MAAM,gBAAgB,GAAG,OAAO,cAAc,CAAC,YAAY,CAAC;AAElE,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,MAAM,GAAG,WAAW,GAAG,QAAQ,CAAC;AACzE,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAC;AAC7D,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,MAAM,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAC;AACrF,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,GAAG,UAAU,CAAC;AACjF,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,QAAQ,CAAC;AAC5C,MAAM,MAAM,YAAY,GAAG,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEtE,MAAM,WAAW,gBAAgB;IAC/B,4DAA4D;IAC5D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mEAAmE;IACnE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gFAAgF;IAChF,yBAAyB,CAAC,EAAE,MAAM,CAAC;IACnC,oCAAoC;IACpC,OAAO,CAAC,EAAE;QACR,kEAAkE;QAClE,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,kEAAkE;QAClE,oBAAoB,CAAC,EAAE,MAAM,CAAC;QAC9B,+DAA+D;QAC/D,kBAAkB,CAAC,EAAE,MAAM,CAAC;QAC5B,qFAAqF;QACrF,oBAAoB,CAAC,EAAE,MAAM,CAAC;KAC/B,CAAC;CACH;AAED,eAAO,MAAM,mBAAmB,EAAE,QAAQ,CAAC,gBAAgB,CAK1D,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-pos",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,16 +19,16 @@
19
19
  "dependencies": {
20
20
  "@hono/zod-openapi": "^1.2.2",
21
21
  "hono": "^4.12.5",
22
- "@porulle/core": "0.8.0",
23
- "@porulle/db": "0.8.0"
22
+ "@porulle/db": "0.10.0",
23
+ "@porulle/core": "0.10.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^24.5.2",
27
27
  "eslint": "^9.39.1",
28
28
  "typescript": "5.9.2",
29
29
  "vitest": "^3.2.4",
30
- "@porulle/eslint-config": "0.1.0",
31
- "@porulle/typescript-config": "0.1.0"
30
+ "@porulle/typescript-config": "0.1.0",
31
+ "@porulle/eslint-config": "0.1.0"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"
@@ -5,7 +5,7 @@
5
5
  * checkout.afterCreate: Updates POS transaction with orderId, increments shift counters.
6
6
  */
7
7
 
8
- import { eq, sql } from "@porulle/core/drizzle";
8
+ import { eq, and, sql } from "@porulle/core/drizzle";
9
9
  import { posTransactions, posShifts } from "../schema.js";
10
10
  import type { Db } from "../types.js";
11
11
 
@@ -79,6 +79,13 @@ export function buildPOSFinalizationHook(getDb: () => Db) {
79
79
  // Increment shift sales counters
80
80
  const posShiftId = metadata?.posShiftId as string | undefined;
81
81
  if (posShiftId) {
82
+ const txnRows = await db
83
+ .select({ organizationId: posTransactions.organizationId })
84
+ .from(posTransactions)
85
+ .where(eq(posTransactions.id, posTransactionId));
86
+ const orgId = txnRows[0]?.organizationId;
87
+ if (!orgId) return;
88
+
82
89
  await db
83
90
  .update(posShifts)
84
91
  .set({
@@ -86,7 +93,7 @@ export function buildPOSFinalizationHook(getDb: () => Db) {
86
93
  salesTotal: sql`${posShifts.salesTotal} + ${result.grandTotal ?? 0}`,
87
94
  updatedAt: new Date(),
88
95
  })
89
- .where(eq(posShifts.id, posShiftId));
96
+ .where(and(eq(posShifts.id, posShiftId), eq(posShifts.organizationId, orgId)));
90
97
  }
91
98
  },
92
99
  };
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  posPayments,
8
8
  posReturnItems,
9
9
  posOperatorPins,
10
+ posPinAttempts,
10
11
  } from "./schema.js";
11
12
  import { TerminalService } from "./services/terminal-service.js";
12
13
  import { ShiftService } from "./services/shift-service.js";
@@ -92,6 +93,7 @@ export function posPlugin(userOptions: POSPluginOptions = {}) {
92
93
  posPayments,
93
94
  posReturnItems,
94
95
  posOperatorPins,
96
+ posPinAttempts,
95
97
  }),
96
98
 
97
99
  // Scope used by PIN login to mint per-shift credentials (issue #51).
@@ -100,6 +102,7 @@ export function posPlugin(userOptions: POSPluginOptions = {}) {
100
102
  prefix: "pos_shift_",
101
103
  description: "Short-lived per-shift POS credentials minted by PIN login.",
102
104
  permissions: { pos: ["operate"] },
105
+ enableMetadata: true,
103
106
  // Allow credentials as short as 15 minutes (Better Auth default
104
107
  // minimum is 1 day — too long for per-shift keys).
105
108
  keyExpiration: { minExpiresIn: 1 / 96 },
@@ -1,8 +1,13 @@
1
- import { router } from "@porulle/core";
1
+ import { router, CommerceValidationError } from "@porulle/core";
2
2
  import { z } from "@hono/zod-openapi";
3
- import type { PinService, PinAuthApi } from "../services/pin-service.js";
3
+ import { PIN_LOCKOUT_ERROR, type PinService, type PinAuthApi } from "../services/pin-service.js";
4
4
  import type { PluginRouteRegistration } from "@porulle/core";
5
5
 
6
+ function throwPinRouteError(message: string): never {
7
+ if (message === PIN_LOCKOUT_ERROR) throw new CommerceValidationError(message);
8
+ throw new Error(message);
9
+ }
10
+
6
11
  /**
7
12
  * PIN auth runtime (issue #51).
8
13
  *
@@ -29,7 +34,7 @@ export function buildPinAuthRoutes(
29
34
  .handler(async ({ input, orgId }) => {
30
35
  const body = input as { operatorId: string; pin: string; canOverride?: boolean };
31
36
  const result = await service.setPin(orgId, body);
32
- if (!result.ok) throw new Error(result.error);
37
+ if (!result.ok) throwPinRouteError(result.error);
33
38
  return result.value;
34
39
  });
35
40
 
@@ -44,7 +49,7 @@ export function buildPinAuthRoutes(
44
49
  .handler(async ({ input, orgId }) => {
45
50
  const body = input as { operatorId: string; pin: string; shiftId?: string };
46
51
  const result = await service.pinLogin(orgId, body, ctx.auth as PinAuthApi | undefined);
47
- if (!result.ok) throw new Error(result.error);
52
+ if (!result.ok) throwPinRouteError(result.error);
48
53
  return result.value;
49
54
  });
50
55
 
@@ -59,7 +64,7 @@ export function buildPinAuthRoutes(
59
64
  .handler(async ({ input, orgId }) => {
60
65
  const body = input as { operatorId: string; pin: string; action: string };
61
66
  const result = await service.override(orgId, body);
62
- if (!result.ok) throw new Error(result.error);
67
+ if (!result.ok) throwPinRouteError(result.error);
63
68
  return result.value;
64
69
  });
65
70
 
@@ -26,7 +26,8 @@ export function buildReturnRoutes(
26
26
  quantity: z.number().int().positive(),
27
27
  reason: z.enum(["defective", "wrong_item", "changed_mind", "other"]),
28
28
  restockingFee: z.number().int().min(0).optional(),
29
- refundAmount: z.number().int().positive(),
29
+ // refundAmount is NOT accepted from the client — it is computed
30
+ // server-side from the original order (SEC-08).
30
31
  })).min(1),
31
32
  }))
32
33
  .handler(async ({ input, actor, orgId }) => {
@@ -39,7 +40,6 @@ export function buildReturnRoutes(
39
40
  quantity: number;
40
41
  reason: "defective" | "wrong_item" | "changed_mind" | "other";
41
42
  restockingFee?: number;
42
- refundAmount: number;
43
43
  }>;
44
44
  };
45
45
 
@@ -62,18 +62,60 @@ export function buildReturnRoutes(
62
62
  });
63
63
  if (!txnResult.ok) throw new Error(txnResult.error);
64
64
 
65
- // Record return items
65
+ // SEC-08: derive the refund server-side via orders.refundLines (the same
66
+ // primitive the exchange flow uses). It validates the order + line items
67
+ // belong to this org, enforces the per-line refundable quantity
68
+ // (quantity - refundedQuantity) and updates that ledger — so a fabricated
69
+ // or foreign order, an over-refund, or a repeat refund of the same units
70
+ // is rejected — and computes the amount from the recorded sale price. The
71
+ // client can no longer set the refund amount.
72
+ const orders = ctx.services?.orders as
73
+ | {
74
+ refundLines(
75
+ orderId: string,
76
+ refundInput: { lines: Array<{ lineItemId: string; quantity: number }>; reason?: string },
77
+ actor: unknown,
78
+ ): Promise<{
79
+ ok: boolean;
80
+ value?: { refund: { amount: number; lines: Array<{ lineItemId: string; quantity: number; amount: number }> } };
81
+ error?: { message?: string };
82
+ }>;
83
+ }
84
+ | undefined;
85
+ if (!orders?.refundLines) {
86
+ throw new Error("Orders service unavailable for return refund");
87
+ }
88
+
89
+ const refundResult = await orders.refundLines(
90
+ body.originalOrderId,
91
+ {
92
+ lines: body.items.map((i) => ({ lineItemId: i.originalLineItemId, quantity: i.quantity })),
93
+ reason: "pos_return",
94
+ },
95
+ actor,
96
+ );
97
+ if (!refundResult.ok || !refundResult.value) {
98
+ throw new Error(
99
+ refundResult.error?.message ??
100
+ "Return could not be validated against the original order.",
101
+ );
102
+ }
103
+ const serverAmountByLine = new Map(
104
+ refundResult.value.refund.lines.map((l) => [l.lineItemId, l.amount]),
105
+ );
106
+ const refundTotal = refundResult.value.refund.amount;
107
+
108
+ // Record return items with the SERVER-computed refund amounts
66
109
  const itemsResult = await returnService.addReturnItems(
67
110
  txnResult.value.id,
68
111
  body.items.map((item) => ({
69
112
  ...item,
70
113
  originalOrderId: body.originalOrderId,
114
+ refundAmount: serverAmountByLine.get(item.originalLineItemId) ?? 0,
71
115
  })),
72
116
  );
73
117
  if (!itemsResult.ok) throw new Error(itemsResult.error);
74
118
 
75
- // Update transaction totals
76
- const refundTotal = body.items.reduce((sum, i) => sum + i.refundAmount, 0);
77
119
  await transactionService.updateTotals(txnResult.value.id, {
78
120
  subtotal: refundTotal,
79
121
  taxTotal: 0,
package/src/schema.ts CHANGED
@@ -134,6 +134,19 @@ export const posOperatorPins = pgTable("pos_operator_pins", {
134
134
  orgOperatorUnique: uniqueIndex("pos_operator_pins_org_operator_unique").on(table.organizationId, table.operatorId),
135
135
  }));
136
136
 
137
+ // ─── PIN attempt lockout (SEC-15) ───────────────────────────────────────
138
+
139
+ export const posPinAttempts = pgTable("pos_pin_attempts", {
140
+ id: uuid("id").defaultRandom().primaryKey(),
141
+ organizationId: text("organization_id").notNull(),
142
+ operatorId: text("operator_id").notNull(),
143
+ failedCount: integer("failed_count").notNull().default(0),
144
+ lockedUntil: timestamp("locked_until", { withTimezone: true }),
145
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
146
+ }, (table) => ({
147
+ orgOperatorUnique: uniqueIndex("pos_pin_attempts_org_operator_unique").on(table.organizationId, table.operatorId),
148
+ }));
149
+
137
150
  // ─── Return Items ───────────────────────────────────────────────────────
138
151
 
139
152
  export const posReturnItems = pgTable("pos_return_items", {
@@ -40,6 +40,19 @@ export interface ExchangeInput {
40
40
  }>;
41
41
  }
42
42
 
43
+ interface PricingService {
44
+ resolve(
45
+ input: {
46
+ entityId: string;
47
+ variantId?: string | undefined;
48
+ currency: string;
49
+ quantity: number;
50
+ },
51
+ actor: unknown,
52
+ ctx?: unknown,
53
+ ): Promise<{ ok: boolean; value?: { finalAmount: number }; error?: { message?: string } }>;
54
+ }
55
+
43
56
  interface CoreOrdersService {
44
57
  refundLines(
45
58
  orderId: string,
@@ -51,6 +64,7 @@ interface CoreOrdersService {
51
64
  input: Record<string, unknown>,
52
65
  actor: unknown,
53
66
  ctx?: unknown,
67
+ opts?: { trustedPricing?: boolean; stockPolicy?: "reserve" | "backorder" },
54
68
  ): Promise<{ ok: boolean; value?: { id: string; grandTotal: number }; error?: { message?: string } }>;
55
69
  }
56
70
 
@@ -79,15 +93,37 @@ export class ExchangeService {
79
93
  if (input.replacementItems.length === 0) return Err("At least one replacement item is required");
80
94
 
81
95
  const orders = this.services.orders as CoreOrdersService;
96
+ const pricing = this.services.pricing as PricingService;
82
97
  const currency = input.currency ?? "USD";
83
98
 
84
- const subtotal = input.replacementItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
85
- const taxTotal = input.replacementItems.reduce((sum, i) => sum + (i.taxAmount ?? 0), 0);
86
- const replacementTotal = subtotal + taxTotal;
87
-
88
99
  // Atomic core half: refund the returned lines + create the replacement.
89
100
  const core = await this.transaction(async (tx) => {
90
101
  const txCtx = { tx, actor };
102
+
103
+ const pricedReplacements: Array<ExchangeInput["replacementItems"][number] & { unitPrice: number }> = [];
104
+ for (const item of input.replacementItems) {
105
+ const resolved = await pricing.resolve(
106
+ {
107
+ entityId: item.entityId,
108
+ currency,
109
+ quantity: item.quantity,
110
+ ...(item.variantId ? { variantId: item.variantId } : {}),
111
+ },
112
+ actor,
113
+ txCtx,
114
+ );
115
+ if (!resolved.ok || !resolved.value) {
116
+ throw new Error(
117
+ resolved.error?.message ?? `Cannot resolve price for replacement item ${item.entityId}`,
118
+ );
119
+ }
120
+ pricedReplacements.push({ ...item, unitPrice: resolved.value.finalAmount });
121
+ }
122
+
123
+ const subtotal = pricedReplacements.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
124
+ const taxTotal = pricedReplacements.reduce((sum, i) => sum + (i.taxAmount ?? 0), 0);
125
+ const replacementTotal = subtotal + taxTotal;
126
+
91
127
  const refund = await orders.refundLines(
92
128
  input.originalOrderId,
93
129
  {
@@ -118,7 +154,7 @@ export class ExchangeService {
118
154
  refundId: refund.value.refund.id,
119
155
  },
120
156
  },
121
- lineItems: input.replacementItems.map((item) => ({
157
+ lineItems: pricedReplacements.map((item) => ({
122
158
  entityId: item.entityId,
123
159
  entityType: "product",
124
160
  ...(item.variantId ? { variantId: item.variantId } : {}),
@@ -132,17 +168,26 @@ export class ExchangeService {
132
168
  },
133
169
  actor,
134
170
  txCtx,
171
+ // Prices are server-resolved above (pricing.resolve); reserve stock for
172
+ // the replacement items so an exchange cannot oversell.
173
+ { trustedPricing: true, stockPolicy: "reserve" },
135
174
  );
136
175
  if (!replacement.ok || !replacement.value) {
137
176
  throw new Error(replacement.error?.message ?? "Replacement order creation failed");
138
177
  }
139
- return { refund: refund.value.refund, replacementOrder: replacement.value };
178
+ return {
179
+ refund: refund.value.refund,
180
+ replacementOrder: replacement.value,
181
+ subtotal,
182
+ taxTotal,
183
+ replacementTotal,
184
+ };
140
185
  }).catch((error: unknown) => error as Error);
141
186
 
142
187
  if (core instanceof Error) return Err(core.message);
143
188
 
144
189
  const returnTotal = core.refund.amount;
145
- const netDelta = replacementTotal - returnTotal;
190
+ const netDelta = core.replacementTotal - returnTotal;
146
191
 
147
192
  // POS bookkeeping (post-commit): cart + exchange transaction + return rows.
148
193
  const cart = this.services.cart as {
@@ -178,8 +223,8 @@ export class ExchangeService {
178
223
  );
179
224
 
180
225
  await this.transactionService.updateTotals(txn.id, {
181
- subtotal,
182
- taxTotal,
226
+ subtotal: core.subtotal,
227
+ taxTotal: core.taxTotal,
183
228
  total: netDelta,
184
229
  discountTotal: 0,
185
230
  });
@@ -194,7 +239,7 @@ export class ExchangeService {
194
239
  refundId: core.refund.id,
195
240
  replacementOrderId: core.replacementOrder.id,
196
241
  returnTotal,
197
- replacementTotal,
242
+ replacementTotal: core.replacementTotal,
198
243
  netDelta,
199
244
  },
200
245
  },
@@ -220,7 +265,7 @@ export class ExchangeService {
220
265
  refundId: core.refund.id,
221
266
  returnTotal,
222
267
  replacementOrderId: core.replacementOrder.id,
223
- replacementTotal,
268
+ replacementTotal: core.replacementTotal,
224
269
  netDelta,
225
270
  });
226
271
  }