@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.
Files changed (38) hide show
  1. package/dist/hooks/checkout-pos.d.ts.map +1 -1
  2. package/dist/hooks/checkout-pos.js +9 -2
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +25 -1
  6. package/dist/routes/auth.d.ts +19 -0
  7. package/dist/routes/auth.d.ts.map +1 -0
  8. package/dist/routes/auth.js +66 -0
  9. package/dist/routes/exchanges.d.ts +15 -0
  10. package/dist/routes/exchanges.d.ts.map +1 -0
  11. package/dist/routes/exchanges.js +42 -0
  12. package/dist/routes/returns.d.ts.map +1 -1
  13. package/dist/routes/returns.js +25 -4
  14. package/dist/schema.d.ts +235 -0
  15. package/dist/schema.d.ts.map +1 -1
  16. package/dist/schema.js +25 -0
  17. package/dist/services/exchange-service.d.ts +55 -0
  18. package/dist/services/exchange-service.d.ts.map +1 -0
  19. package/dist/services/exchange-service.js +168 -0
  20. package/dist/services/pin-service.d.ts +73 -0
  21. package/dist/services/pin-service.d.ts.map +1 -0
  22. package/dist/services/pin-service.js +269 -0
  23. package/dist/services/transaction-service.d.ts.map +1 -1
  24. package/dist/services/transaction-service.js +5 -5
  25. package/dist/types.d.ts +16 -1
  26. package/dist/types.d.ts.map +1 -1
  27. package/dist/types.js +1 -0
  28. package/package.json +3 -3
  29. package/src/hooks/checkout-pos.ts +9 -2
  30. package/src/index.ts +33 -0
  31. package/src/routes/auth.ts +72 -0
  32. package/src/routes/exchanges.ts +53 -0
  33. package/src/routes/returns.ts +47 -5
  34. package/src/schema.ts +29 -0
  35. package/src/services/exchange-service.ts +272 -0
  36. package/src/services/pin-service.ts +350 -0
  37. package/src/services/transaction-service.ts +5 -4
  38. package/src/types.ts +20 -0
@@ -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,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,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;CACpC;AAED,eAAO,MAAM,mBAAmB,EAAE,QAAQ,CAAC,gBAAgB,CAI1D,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/dist/types.js CHANGED
@@ -2,4 +2,5 @@ export const DEFAULT_POS_OPTIONS = {
2
2
  defaultCurrency: "USD",
3
3
  maxHoldHours: 24,
4
4
  discountOverrideThreshold: 20,
5
+ pinAuth: {},
5
6
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-pos",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,8 +19,8 @@
19
19
  "dependencies": {
20
20
  "@hono/zod-openapi": "^1.2.2",
21
21
  "hono": "^4.12.5",
22
- "@porulle/core": "0.7.0",
23
- "@porulle/db": "0.7.0"
22
+ "@porulle/core": "0.9.0",
23
+ "@porulle/db": "0.9.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^24.5.2",
@@ -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
@@ -6,6 +6,8 @@ import {
6
6
  posTransactions,
7
7
  posPayments,
8
8
  posReturnItems,
9
+ posOperatorPins,
10
+ posPinAttempts,
9
11
  } from "./schema.js";
10
12
  import { TerminalService } from "./services/terminal-service.js";
11
13
  import { ShiftService } from "./services/shift-service.js";
@@ -14,6 +16,8 @@ import { PaymentService } from "./services/payment-service.js";
14
16
  import { ReturnService } from "./services/return-service.js";
15
17
  import { LookupService } from "./services/lookup-service.js";
16
18
  import { ReceiptService } from "./services/receipt-service.js";
19
+ import { PinService } from "./services/pin-service.js";
20
+ import { ExchangeService } from "./services/exchange-service.js";
17
21
  import { buildTerminalRoutes } from "./routes/terminals.js";
18
22
  import { buildShiftRoutes } from "./routes/shifts.js";
19
23
  import { buildTransactionRoutes } from "./routes/transactions.js";
@@ -21,6 +25,8 @@ import { buildPaymentRoutes } from "./routes/payments.js";
21
25
  import { buildReturnRoutes } from "./routes/returns.js";
22
26
  import { buildLookupRoutes } from "./routes/lookup.js";
23
27
  import { buildReceiptRoutes } from "./routes/receipts.js";
28
+ import { buildPinAuthRoutes } from "./routes/auth.js";
29
+ import { buildExchangeRoutes } from "./routes/exchanges.js";
24
30
  import { buildPOSShippingHook, buildPOSFinalizationHook } from "./hooks/checkout-pos.js";
25
31
  import type { POSPluginOptions, Db } from "./types.js";
26
32
  import { DEFAULT_POS_OPTIONS } from "./types.js";
@@ -33,6 +39,8 @@ export { PaymentService } from "./services/payment-service.js";
33
39
  export { ReturnService } from "./services/return-service.js";
34
40
  export { LookupService } from "./services/lookup-service.js";
35
41
  export { ReceiptService } from "./services/receipt-service.js";
42
+ export { PinService, hashPin, verifyPinHash } from "./services/pin-service.js";
43
+ export { ExchangeService } from "./services/exchange-service.js";
36
44
  export { createPOSPaymentAdapter } from "./payment-adapter.js";
37
45
 
38
46
  /**
@@ -84,6 +92,21 @@ export function posPlugin(userOptions: POSPluginOptions = {}) {
84
92
  posTransactions,
85
93
  posPayments,
86
94
  posReturnItems,
95
+ posOperatorPins,
96
+ posPinAttempts,
97
+ }),
98
+
99
+ // Scope used by PIN login to mint per-shift credentials (issue #51).
100
+ apiKeyScopes: () => ({
101
+ [options.pinAuth.apiKeyScope ?? "pos"]: {
102
+ prefix: "pos_shift_",
103
+ description: "Short-lived per-shift POS credentials minted by PIN login.",
104
+ permissions: { pos: ["operate"] },
105
+ enableMetadata: true,
106
+ // Allow credentials as short as 15 minutes (Better Auth default
107
+ // minimum is 1 day — too long for per-shift keys).
108
+ keyExpiration: { minExpiresIn: 1 / 96 },
109
+ },
87
110
  }),
88
111
 
89
112
  hooks: () => [
@@ -111,6 +134,14 @@ export function posPlugin(userOptions: POSPluginOptions = {}) {
111
134
  const returnService = new ReturnService(db);
112
135
  const lookupService = new LookupService(db, ctx.services);
113
136
  const receiptService = new ReceiptService(db, ctx.services);
137
+ const pinService = new PinService(db, options.pinAuth);
138
+ const exchangeService = new ExchangeService(
139
+ db,
140
+ ctx.services,
141
+ transactionFn as <T>(fn: (tx: typeof db) => Promise<T>) => Promise<T>,
142
+ transactionService,
143
+ returnService,
144
+ );
114
145
 
115
146
  // Cart service from core (for creating POS transaction carts)
116
147
  const cartService = ctx.services.cart as {
@@ -125,6 +156,8 @@ export function posPlugin(userOptions: POSPluginOptions = {}) {
125
156
  ...buildReturnRoutes(returnService, transactionService, paymentService, cartService, ctx),
126
157
  ...buildLookupRoutes(lookupService, ctx),
127
158
  ...buildReceiptRoutes(receiptService, ctx),
159
+ ...buildPinAuthRoutes(pinService, ctx),
160
+ ...buildExchangeRoutes(exchangeService, ctx),
128
161
  ];
129
162
  },
130
163
  });
@@ -0,0 +1,72 @@
1
+ import { router, CommerceValidationError } from "@porulle/core";
2
+ import { z } from "@hono/zod-openapi";
3
+ import { PIN_LOCKOUT_ERROR, type PinService, type PinAuthApi } from "../services/pin-service.js";
4
+ import type { PluginRouteRegistration } from "@porulle/core";
5
+
6
+ function throwPinRouteError(message: string): never {
7
+ if (message === PIN_LOCKOUT_ERROR) throw new CommerceValidationError(message);
8
+ throw new Error(message);
9
+ }
10
+
11
+ /**
12
+ * PIN auth runtime (issue #51).
13
+ *
14
+ * - PUT /pos/auth/pin — set/rotate an operator's PIN (pos:admin)
15
+ * - POST /pos/auth/pin-login — operator PIN → short-lived per-shift API key.
16
+ * Authenticated by the terminal's device credential (pos:operate); the
17
+ * minted key is personal and carries pos:operate + shift/terminal metadata.
18
+ * - POST /pos/auth/override — manager PIN approves one elevated action.
19
+ */
20
+ export function buildPinAuthRoutes(
21
+ service: PinService,
22
+ ctx: { services?: Record<string, unknown>; database?: { db: unknown }; auth?: unknown },
23
+ ): PluginRouteRegistration[] {
24
+ const r = router("POS Auth", "/pos/auth", ctx);
25
+
26
+ r.put("/pin")
27
+ .summary("Set or rotate an operator PIN")
28
+ .permission("pos:admin")
29
+ .input(z.object({
30
+ operatorId: z.string().min(1),
31
+ pin: z.string().regex(/^\d{4,8}$/, "PIN must be 4-8 digits"),
32
+ canOverride: z.boolean().optional(),
33
+ }))
34
+ .handler(async ({ input, orgId }) => {
35
+ const body = input as { operatorId: string; pin: string; canOverride?: boolean };
36
+ const result = await service.setPin(orgId, body);
37
+ if (!result.ok) throwPinRouteError(result.error);
38
+ return result.value;
39
+ });
40
+
41
+ r.post("/pin-login")
42
+ .summary("PIN login — mint a short-lived per-shift credential")
43
+ .permission("pos:operate")
44
+ .input(z.object({
45
+ operatorId: z.string().min(1),
46
+ pin: z.string().regex(/^\d{4,8}$/),
47
+ shiftId: z.string().uuid().optional(),
48
+ }))
49
+ .handler(async ({ input, orgId }) => {
50
+ const body = input as { operatorId: string; pin: string; shiftId?: string };
51
+ const result = await service.pinLogin(orgId, body, ctx.auth as PinAuthApi | undefined);
52
+ if (!result.ok) throwPinRouteError(result.error);
53
+ return result.value;
54
+ });
55
+
56
+ r.post("/override")
57
+ .summary("Manager override — approve one elevated action by PIN")
58
+ .permission("pos:operate")
59
+ .input(z.object({
60
+ operatorId: z.string().min(1),
61
+ pin: z.string().regex(/^\d{4,8}$/),
62
+ action: z.string().min(1).max(200),
63
+ }))
64
+ .handler(async ({ input, orgId }) => {
65
+ const body = input as { operatorId: string; pin: string; action: string };
66
+ const result = await service.override(orgId, body);
67
+ if (!result.ok) throwPinRouteError(result.error);
68
+ return result.value;
69
+ });
70
+
71
+ return r.routes();
72
+ }
@@ -0,0 +1,53 @@
1
+ import { router } from "@porulle/core";
2
+ import { z } from "@hono/zod-openapi";
3
+ import type { ExchangeService, ExchangeInput } from "../services/exchange-service.js";
4
+ import type { PluginRouteRegistration } from "@porulle/core";
5
+
6
+ /**
7
+ * Exchanges (issue #53): POST /pos/exchanges — return + replacement order +
8
+ * net delta in one call. Requires pos:manage (like returns) plus core order
9
+ * scopes on the actor (orders:update for the refund, orders:create for the
10
+ * replacement) since the exchange moves money on core orders.
11
+ */
12
+ export function buildExchangeRoutes(
13
+ service: ExchangeService,
14
+ ctx: { services?: Record<string, unknown>; database?: { db: unknown } },
15
+ ): PluginRouteRegistration[] {
16
+ const r = router("POS Exchanges", "/pos/exchanges", ctx);
17
+
18
+ r.post("/")
19
+ .summary("Exchange — return lines + replacement order in one atomic operation")
20
+ .permission("pos:manage")
21
+ .input(z.object({
22
+ shiftId: z.string().uuid(),
23
+ terminalId: z.string().uuid(),
24
+ originalOrderId: z.string().uuid(),
25
+ currency: z.string().length(3).optional(),
26
+ customerId: z.string().uuid().optional(),
27
+ returnItems: z.array(z.object({
28
+ originalLineItemId: z.string().uuid(),
29
+ quantity: z.number().int().positive(),
30
+ reason: z.enum(["defective", "wrong_item", "changed_mind", "other"]),
31
+ })).min(1),
32
+ replacementItems: z.array(z.object({
33
+ entityId: z.string().uuid(),
34
+ variantId: z.string().uuid().optional(),
35
+ sku: z.string().optional(),
36
+ title: z.string().min(1),
37
+ quantity: z.number().int().positive(),
38
+ unitPrice: z.number().int().min(0),
39
+ taxAmount: z.number().int().min(0).optional(),
40
+ })).min(1),
41
+ }))
42
+ .handler(async ({ input, actor, orgId }) => {
43
+ const result = await service.exchange(
44
+ orgId,
45
+ input as ExchangeInput,
46
+ actor as { userId: string } & Record<string, unknown>,
47
+ );
48
+ if (!result.ok) throw new Error(result.error);
49
+ return result.value;
50
+ });
51
+
52
+ return r.routes();
53
+ }
@@ -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
@@ -118,6 +118,35 @@ export const posPayments = pgTable("pos_payments", {
118
118
  transactionIdx: index("idx_pos_payments_transaction").on(table.transactionId),
119
119
  }));
120
120
 
121
+ // ─── Operator PINs (issue #51) ──────────────────────────────────────────
122
+
123
+ export const posOperatorPins = pgTable("pos_operator_pins", {
124
+ id: uuid("id").defaultRandom().primaryKey(),
125
+ organizationId: text("organization_id").notNull(),
126
+ operatorId: text("operator_id").notNull(),
127
+ // PBKDF2-SHA256, encoded as pbkdf2$<iterations>$<saltB64>$<hashB64>
128
+ pinHash: text("pin_hash").notNull(),
129
+ // Manager override capability — checked by POST /pos/auth/override.
130
+ canOverride: boolean("can_override").notNull().default(false),
131
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
132
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
133
+ }, (table) => ({
134
+ orgOperatorUnique: uniqueIndex("pos_operator_pins_org_operator_unique").on(table.organizationId, table.operatorId),
135
+ }));
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
+
121
150
  // ─── Return Items ───────────────────────────────────────────────────────
122
151
 
123
152
  export const posReturnItems = pgTable("pos_return_items", {
@@ -0,0 +1,272 @@
1
+ import { eq } from "@porulle/core/drizzle";
2
+ import { Ok, Err } from "@porulle/core";
3
+ import type { PluginResult } from "@porulle/core";
4
+ import { posTransactions } from "../schema.js";
5
+ import type { Db, Transaction } from "../types.js";
6
+ import type { ReturnService } from "./return-service.js";
7
+ import type { TransactionService } from "./transaction-service.js";
8
+
9
+ /**
10
+ * Exchanges (issue #53): return line items + create a replacement order +
11
+ * settle the difference, in one call.
12
+ *
13
+ * The financially-critical half — the line-level refund on the original
14
+ * order (core issue #52 primitives: refundable-quantity enforcement, daily
15
+ * cap, audited ledger) and the replacement order creation — runs inside ONE
16
+ * database transaction, so a failure in either leaves no money moved and no
17
+ * order created. The POS bookkeeping rows (transaction, return items) are
18
+ * written after commit and cross-linked for audit.
19
+ */
20
+
21
+ export interface ExchangeInput {
22
+ shiftId: string;
23
+ terminalId: string;
24
+ originalOrderId: string;
25
+ currency?: string | undefined;
26
+ customerId?: string | undefined;
27
+ returnItems: Array<{
28
+ originalLineItemId: string;
29
+ quantity: number;
30
+ reason: "defective" | "wrong_item" | "changed_mind" | "other";
31
+ }>;
32
+ replacementItems: Array<{
33
+ entityId: string;
34
+ variantId?: string | undefined;
35
+ sku?: string | undefined;
36
+ title: string;
37
+ quantity: number;
38
+ unitPrice: number;
39
+ taxAmount?: number | undefined;
40
+ }>;
41
+ }
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
+
56
+ interface CoreOrdersService {
57
+ refundLines(
58
+ orderId: string,
59
+ input: { lines: Array<{ lineItemId: string; quantity: number }>; reason?: string },
60
+ actor: unknown,
61
+ ctx?: unknown,
62
+ ): Promise<{ ok: boolean; value?: { refund: { id: string; amount: number; lines: Array<{ lineItemId: string; quantity: number; amount: number }> } }; error?: { message?: string } }>;
63
+ create(
64
+ input: Record<string, unknown>,
65
+ actor: unknown,
66
+ ctx?: unknown,
67
+ opts?: { trustedPricing?: boolean; stockPolicy?: "reserve" | "backorder" },
68
+ ): Promise<{ ok: boolean; value?: { id: string; grandTotal: number }; error?: { message?: string } }>;
69
+ }
70
+
71
+ export class ExchangeService {
72
+ constructor(
73
+ private db: Db,
74
+ private services: Record<string, unknown>,
75
+ private transaction: <T>(fn: (tx: Db) => Promise<T>) => Promise<T>,
76
+ private transactionService: TransactionService,
77
+ private returnService: ReturnService,
78
+ ) {}
79
+
80
+ async exchange(
81
+ orgId: string,
82
+ input: ExchangeInput,
83
+ actor: { userId: string } & Record<string, unknown>,
84
+ ): Promise<PluginResult<{
85
+ transaction: Transaction;
86
+ refundId: string;
87
+ returnTotal: number;
88
+ replacementOrderId: string;
89
+ replacementTotal: number;
90
+ netDelta: number;
91
+ }>> {
92
+ if (input.returnItems.length === 0) return Err("At least one return item is required");
93
+ if (input.replacementItems.length === 0) return Err("At least one replacement item is required");
94
+
95
+ const orders = this.services.orders as CoreOrdersService;
96
+ const pricing = this.services.pricing as PricingService;
97
+ const currency = input.currency ?? "USD";
98
+
99
+ // Atomic core half: refund the returned lines + create the replacement.
100
+ const core = await this.transaction(async (tx) => {
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
+
127
+ const refund = await orders.refundLines(
128
+ input.originalOrderId,
129
+ {
130
+ lines: input.returnItems.map((item) => ({
131
+ lineItemId: item.originalLineItemId,
132
+ quantity: item.quantity,
133
+ })),
134
+ reason: "exchange",
135
+ },
136
+ actor,
137
+ txCtx,
138
+ );
139
+ if (!refund.ok || !refund.value) {
140
+ throw new Error(refund.error?.message ?? "Exchange refund failed");
141
+ }
142
+
143
+ const replacement = await orders.create(
144
+ {
145
+ currency,
146
+ subtotal,
147
+ taxTotal,
148
+ shippingTotal: 0,
149
+ grandTotal: replacementTotal,
150
+ ...(input.customerId ? { customerId: input.customerId } : {}),
151
+ metadata: {
152
+ exchange: {
153
+ originalOrderId: input.originalOrderId,
154
+ refundId: refund.value.refund.id,
155
+ },
156
+ },
157
+ lineItems: pricedReplacements.map((item) => ({
158
+ entityId: item.entityId,
159
+ entityType: "product",
160
+ ...(item.variantId ? { variantId: item.variantId } : {}),
161
+ ...(item.sku ? { sku: item.sku } : {}),
162
+ title: item.title,
163
+ quantity: item.quantity,
164
+ unitPrice: item.unitPrice,
165
+ totalPrice: item.unitPrice * item.quantity,
166
+ taxAmount: item.taxAmount ?? 0,
167
+ })),
168
+ },
169
+ actor,
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" },
174
+ );
175
+ if (!replacement.ok || !replacement.value) {
176
+ throw new Error(replacement.error?.message ?? "Replacement order creation failed");
177
+ }
178
+ return {
179
+ refund: refund.value.refund,
180
+ replacementOrder: replacement.value,
181
+ subtotal,
182
+ taxTotal,
183
+ replacementTotal,
184
+ };
185
+ }).catch((error: unknown) => error as Error);
186
+
187
+ if (core instanceof Error) return Err(core.message);
188
+
189
+ const returnTotal = core.refund.amount;
190
+ const netDelta = core.replacementTotal - returnTotal;
191
+
192
+ // POS bookkeeping (post-commit): cart + exchange transaction + return rows.
193
+ const cart = this.services.cart as {
194
+ create(input: Record<string, unknown>, actor: unknown): Promise<{ ok: boolean; value?: { id: string } }>;
195
+ };
196
+ const cartResult = await cart.create(
197
+ { currency, metadata: { posExchange: true, originalOrderId: input.originalOrderId } },
198
+ actor,
199
+ );
200
+ if (!cartResult.ok || !cartResult.value) return Err("Failed to create cart for exchange");
201
+
202
+ const txnResult = await this.transactionService.create(orgId, {
203
+ shiftId: input.shiftId,
204
+ terminalId: input.terminalId,
205
+ operatorId: actor.userId,
206
+ cartId: cartResult.value.id,
207
+ type: "exchange",
208
+ ...(input.customerId ? { customerId: input.customerId } : {}),
209
+ });
210
+ if (!txnResult.ok) return Err(txnResult.error);
211
+ const txn = txnResult.value;
212
+
213
+ await this.returnService.addReturnItems(
214
+ txn.id,
215
+ input.returnItems.map((item) => ({
216
+ originalOrderId: input.originalOrderId,
217
+ originalLineItemId: item.originalLineItemId,
218
+ quantity: item.quantity,
219
+ reason: item.reason,
220
+ refundAmount:
221
+ core.refund.lines.find((l) => l.lineItemId === item.originalLineItemId)?.amount ?? 0,
222
+ })),
223
+ );
224
+
225
+ await this.transactionService.updateTotals(txn.id, {
226
+ subtotal: core.subtotal,
227
+ taxTotal: core.taxTotal,
228
+ total: netDelta,
229
+ discountTotal: 0,
230
+ });
231
+ // Cross-link everything on the POS transaction for audit.
232
+ await this.db
233
+ .update(posTransactions)
234
+ .set({
235
+ orderId: core.replacementOrder.id,
236
+ metadata: {
237
+ exchange: {
238
+ originalOrderId: input.originalOrderId,
239
+ refundId: core.refund.id,
240
+ replacementOrderId: core.replacementOrder.id,
241
+ returnTotal,
242
+ replacementTotal: core.replacementTotal,
243
+ netDelta,
244
+ },
245
+ },
246
+ updatedAt: new Date(),
247
+ })
248
+ .where(eq(posTransactions.id, txn.id));
249
+
250
+ // An even exchange settles itself — nothing to tender.
251
+ let finalTxn = txn;
252
+ if (netDelta === 0) {
253
+ const completed = await this.transactionService.complete(txn.id, core.replacementOrder.id);
254
+ if (completed.ok) finalTxn = completed.value;
255
+ } else {
256
+ const rows = await this.db
257
+ .select()
258
+ .from(posTransactions)
259
+ .where(eq(posTransactions.id, txn.id));
260
+ if (rows[0]) finalTxn = rows[0] as Transaction;
261
+ }
262
+
263
+ return Ok({
264
+ transaction: finalTxn,
265
+ refundId: core.refund.id,
266
+ returnTotal,
267
+ replacementOrderId: core.replacementOrder.id,
268
+ replacementTotal: core.replacementTotal,
269
+ netDelta,
270
+ });
271
+ }
272
+ }