@porulle/plugin-pos 0.7.0 → 0.8.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/index.d.ts CHANGED
@@ -7,6 +7,8 @@ export { PaymentService } from "./services/payment-service.js";
7
7
  export { ReturnService } from "./services/return-service.js";
8
8
  export { LookupService } from "./services/lookup-service.js";
9
9
  export { ReceiptService } from "./services/receipt-service.js";
10
+ export { PinService, hashPin, verifyPinHash } from "./services/pin-service.js";
11
+ export { ExchangeService } from "./services/exchange-service.js";
10
12
  export { createPOSPaymentAdapter } from "./payment-adapter.js";
11
13
  /**
12
14
  * POS Plugin — Tier 0 Core Primitives
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAE,gBAAgB,EAAM,MAAM,YAAY,CAAC;AAGvD,YAAY,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AACnD,OAAO,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AACjE,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,EAAE,kBAAkB,EAAE,MAAM,mCAAmC,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAC/D,OAAO,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAE/D;;;;;;;;;;;;;GAaG;AACH,wBAAgB,SAAS,CAAC,WAAW,GAAE,gBAAqB,0CA+E3D"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA6BA,OAAO,KAAK,EAAE,gBAAgB,EAAM,MAAM,YAAY,CAAC;AAGvD,YAAY,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AACnD,OAAO,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AACjE,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,EAAE,kBAAkB,EAAE,MAAM,mCAAmC,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC/E,OAAO,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AACjE,OAAO,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAE/D;;;;;;;;;;;;;GAaG;AACH,wBAAgB,SAAS,CAAC,WAAW,GAAE,gBAAqB,0CAsG3D"}
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineCommercePlugin } from "@porulle/core";
2
- import { posTerminals, posShifts, posCashEvents, posTransactions, posPayments, posReturnItems, } from "./schema.js";
2
+ import { posTerminals, posShifts, posCashEvents, posTransactions, posPayments, posReturnItems, posOperatorPins, } from "./schema.js";
3
3
  import { TerminalService } from "./services/terminal-service.js";
4
4
  import { ShiftService } from "./services/shift-service.js";
5
5
  import { TransactionService } from "./services/transaction-service.js";
@@ -7,6 +7,8 @@ import { PaymentService } from "./services/payment-service.js";
7
7
  import { ReturnService } from "./services/return-service.js";
8
8
  import { LookupService } from "./services/lookup-service.js";
9
9
  import { ReceiptService } from "./services/receipt-service.js";
10
+ import { PinService } from "./services/pin-service.js";
11
+ import { ExchangeService } from "./services/exchange-service.js";
10
12
  import { buildTerminalRoutes } from "./routes/terminals.js";
11
13
  import { buildShiftRoutes } from "./routes/shifts.js";
12
14
  import { buildTransactionRoutes } from "./routes/transactions.js";
@@ -14,6 +16,8 @@ import { buildPaymentRoutes } from "./routes/payments.js";
14
16
  import { buildReturnRoutes } from "./routes/returns.js";
15
17
  import { buildLookupRoutes } from "./routes/lookup.js";
16
18
  import { buildReceiptRoutes } from "./routes/receipts.js";
19
+ import { buildPinAuthRoutes } from "./routes/auth.js";
20
+ import { buildExchangeRoutes } from "./routes/exchanges.js";
17
21
  import { buildPOSShippingHook, buildPOSFinalizationHook } from "./hooks/checkout-pos.js";
18
22
  import { DEFAULT_POS_OPTIONS } from "./types.js";
19
23
  export { TerminalService } from "./services/terminal-service.js";
@@ -23,6 +27,8 @@ export { PaymentService } from "./services/payment-service.js";
23
27
  export { ReturnService } from "./services/return-service.js";
24
28
  export { LookupService } from "./services/lookup-service.js";
25
29
  export { ReceiptService } from "./services/receipt-service.js";
30
+ export { PinService, hashPin, verifyPinHash } from "./services/pin-service.js";
31
+ export { ExchangeService } from "./services/exchange-service.js";
26
32
  export { createPOSPaymentAdapter } from "./payment-adapter.js";
27
33
  /**
28
34
  * POS Plugin — Tier 0 Core Primitives
@@ -69,6 +75,18 @@ export function posPlugin(userOptions = {}) {
69
75
  posTransactions,
70
76
  posPayments,
71
77
  posReturnItems,
78
+ posOperatorPins,
79
+ }),
80
+ // Scope used by PIN login to mint per-shift credentials (issue #51).
81
+ apiKeyScopes: () => ({
82
+ [options.pinAuth.apiKeyScope ?? "pos"]: {
83
+ prefix: "pos_shift_",
84
+ description: "Short-lived per-shift POS credentials minted by PIN login.",
85
+ permissions: { pos: ["operate"] },
86
+ // Allow credentials as short as 15 minutes (Better Auth default
87
+ // minimum is 1 day — too long for per-shift keys).
88
+ keyExpiration: { minExpiresIn: 1 / 96 },
89
+ },
72
90
  }),
73
91
  hooks: () => [
74
92
  buildPOSShippingHook(),
@@ -93,6 +111,8 @@ export function posPlugin(userOptions = {}) {
93
111
  const returnService = new ReturnService(db);
94
112
  const lookupService = new LookupService(db, ctx.services);
95
113
  const receiptService = new ReceiptService(db, ctx.services);
114
+ const pinService = new PinService(db, options.pinAuth);
115
+ const exchangeService = new ExchangeService(db, ctx.services, transactionFn, transactionService, returnService);
96
116
  // Cart service from core (for creating POS transaction carts)
97
117
  const cartService = ctx.services.cart;
98
118
  return [
@@ -103,6 +123,8 @@ export function posPlugin(userOptions = {}) {
103
123
  ...buildReturnRoutes(returnService, transactionService, paymentService, cartService, ctx),
104
124
  ...buildLookupRoutes(lookupService, ctx),
105
125
  ...buildReceiptRoutes(receiptService, ctx),
126
+ ...buildPinAuthRoutes(pinService, ctx),
127
+ ...buildExchangeRoutes(exchangeService, ctx),
106
128
  ];
107
129
  },
108
130
  });
@@ -0,0 +1,19 @@
1
+ import type { PinService } from "../services/pin-service.js";
2
+ import type { PluginRouteRegistration } from "@porulle/core";
3
+ /**
4
+ * PIN auth runtime (issue #51).
5
+ *
6
+ * - PUT /pos/auth/pin — set/rotate an operator's PIN (pos:admin)
7
+ * - POST /pos/auth/pin-login — operator PIN → short-lived per-shift API key.
8
+ * Authenticated by the terminal's device credential (pos:operate); the
9
+ * minted key is personal and carries pos:operate + shift/terminal metadata.
10
+ * - POST /pos/auth/override — manager PIN approves one elevated action.
11
+ */
12
+ export declare function buildPinAuthRoutes(service: PinService, ctx: {
13
+ services?: Record<string, unknown>;
14
+ database?: {
15
+ db: unknown;
16
+ };
17
+ auth?: unknown;
18
+ }): PluginRouteRegistration[];
19
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/routes/auth.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAc,MAAM,4BAA4B,CAAC;AACzE,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AAE7D;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,UAAU,EACnB,GAAG,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,QAAQ,CAAC,EAAE;QAAE,EAAE,EAAE,OAAO,CAAA;KAAE,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,GACtF,uBAAuB,EAAE,CAiD3B"}
@@ -0,0 +1,60 @@
1
+ import { router } from "@porulle/core";
2
+ import { z } from "@hono/zod-openapi";
3
+ /**
4
+ * PIN auth runtime (issue #51).
5
+ *
6
+ * - PUT /pos/auth/pin — set/rotate an operator's PIN (pos:admin)
7
+ * - POST /pos/auth/pin-login — operator PIN → short-lived per-shift API key.
8
+ * Authenticated by the terminal's device credential (pos:operate); the
9
+ * minted key is personal and carries pos:operate + shift/terminal metadata.
10
+ * - POST /pos/auth/override — manager PIN approves one elevated action.
11
+ */
12
+ export function buildPinAuthRoutes(service, ctx) {
13
+ const r = router("POS Auth", "/pos/auth", ctx);
14
+ r.put("/pin")
15
+ .summary("Set or rotate an operator PIN")
16
+ .permission("pos:admin")
17
+ .input(z.object({
18
+ operatorId: z.string().min(1),
19
+ pin: z.string().regex(/^\d{4,8}$/, "PIN must be 4-8 digits"),
20
+ canOverride: z.boolean().optional(),
21
+ }))
22
+ .handler(async ({ input, orgId }) => {
23
+ const body = input;
24
+ const result = await service.setPin(orgId, body);
25
+ if (!result.ok)
26
+ throw new Error(result.error);
27
+ return result.value;
28
+ });
29
+ r.post("/pin-login")
30
+ .summary("PIN login — mint a short-lived per-shift credential")
31
+ .permission("pos:operate")
32
+ .input(z.object({
33
+ operatorId: z.string().min(1),
34
+ pin: z.string().regex(/^\d{4,8}$/),
35
+ shiftId: z.string().uuid().optional(),
36
+ }))
37
+ .handler(async ({ input, orgId }) => {
38
+ const body = input;
39
+ const result = await service.pinLogin(orgId, body, ctx.auth);
40
+ if (!result.ok)
41
+ throw new Error(result.error);
42
+ return result.value;
43
+ });
44
+ r.post("/override")
45
+ .summary("Manager override — approve one elevated action by PIN")
46
+ .permission("pos:operate")
47
+ .input(z.object({
48
+ operatorId: z.string().min(1),
49
+ pin: z.string().regex(/^\d{4,8}$/),
50
+ action: z.string().min(1).max(200),
51
+ }))
52
+ .handler(async ({ input, orgId }) => {
53
+ const body = input;
54
+ const result = await service.override(orgId, body);
55
+ if (!result.ok)
56
+ throw new Error(result.error);
57
+ return result.value;
58
+ });
59
+ return r.routes();
60
+ }
@@ -0,0 +1,15 @@
1
+ import type { ExchangeService } from "../services/exchange-service.js";
2
+ import type { PluginRouteRegistration } from "@porulle/core";
3
+ /**
4
+ * Exchanges (issue #53): POST /pos/exchanges — return + replacement order +
5
+ * net delta in one call. Requires pos:manage (like returns) plus core order
6
+ * scopes on the actor (orders:update for the refund, orders:create for the
7
+ * replacement) since the exchange moves money on core orders.
8
+ */
9
+ export declare function buildExchangeRoutes(service: ExchangeService, ctx: {
10
+ services?: Record<string, unknown>;
11
+ database?: {
12
+ db: unknown;
13
+ };
14
+ }): PluginRouteRegistration[];
15
+ //# sourceMappingURL=exchanges.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exchanges.d.ts","sourceRoot":"","sources":["../../src/routes/exchanges.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAiB,MAAM,iCAAiC,CAAC;AACtF,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AAE7D;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,eAAe,EACxB,GAAG,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,QAAQ,CAAC,EAAE;QAAE,EAAE,EAAE,OAAO,CAAA;KAAE,CAAA;CAAE,GACtE,uBAAuB,EAAE,CAsC3B"}
@@ -0,0 +1,42 @@
1
+ import { router } from "@porulle/core";
2
+ import { z } from "@hono/zod-openapi";
3
+ /**
4
+ * Exchanges (issue #53): POST /pos/exchanges — return + replacement order +
5
+ * net delta in one call. Requires pos:manage (like returns) plus core order
6
+ * scopes on the actor (orders:update for the refund, orders:create for the
7
+ * replacement) since the exchange moves money on core orders.
8
+ */
9
+ export function buildExchangeRoutes(service, ctx) {
10
+ const r = router("POS Exchanges", "/pos/exchanges", ctx);
11
+ r.post("/")
12
+ .summary("Exchange — return lines + replacement order in one atomic operation")
13
+ .permission("pos:manage")
14
+ .input(z.object({
15
+ shiftId: z.string().uuid(),
16
+ terminalId: z.string().uuid(),
17
+ originalOrderId: z.string().uuid(),
18
+ currency: z.string().length(3).optional(),
19
+ customerId: z.string().uuid().optional(),
20
+ returnItems: z.array(z.object({
21
+ originalLineItemId: z.string().uuid(),
22
+ quantity: z.number().int().positive(),
23
+ reason: z.enum(["defective", "wrong_item", "changed_mind", "other"]),
24
+ })).min(1),
25
+ replacementItems: z.array(z.object({
26
+ entityId: z.string().uuid(),
27
+ variantId: z.string().uuid().optional(),
28
+ sku: z.string().optional(),
29
+ title: z.string().min(1),
30
+ quantity: z.number().int().positive(),
31
+ unitPrice: z.number().int().min(0),
32
+ taxAmount: z.number().int().min(0).optional(),
33
+ })).min(1),
34
+ }))
35
+ .handler(async ({ input, actor, orgId }) => {
36
+ const result = await service.exchange(orgId, input, actor);
37
+ if (!result.ok)
38
+ throw new Error(result.error);
39
+ return result.value;
40
+ });
41
+ return r.routes();
42
+ }
package/dist/schema.d.ts CHANGED
@@ -1174,6 +1174,132 @@ export declare const posPayments: import("drizzle-orm/pg-core/table").PgTableWit
1174
1174
  };
1175
1175
  dialect: "pg";
1176
1176
  }>;
1177
+ export declare const posOperatorPins: import("drizzle-orm/pg-core/table").PgTableWithColumns<{
1178
+ name: "pos_operator_pins";
1179
+ schema: undefined;
1180
+ columns: {
1181
+ id: import("@porulle/core/drizzle").PgColumn<{
1182
+ name: "id";
1183
+ tableName: "pos_operator_pins";
1184
+ dataType: "string";
1185
+ columnType: "PgUUID";
1186
+ data: string;
1187
+ driverParam: string;
1188
+ notNull: true;
1189
+ hasDefault: true;
1190
+ isPrimaryKey: true;
1191
+ isAutoincrement: false;
1192
+ hasRuntimeDefault: false;
1193
+ enumValues: undefined;
1194
+ baseColumn: never;
1195
+ identity: undefined;
1196
+ generated: undefined;
1197
+ }, {}, {}>;
1198
+ organizationId: import("@porulle/core/drizzle").PgColumn<{
1199
+ name: "organization_id";
1200
+ tableName: "pos_operator_pins";
1201
+ dataType: "string";
1202
+ columnType: "PgText";
1203
+ data: string;
1204
+ driverParam: string;
1205
+ notNull: true;
1206
+ hasDefault: false;
1207
+ isPrimaryKey: false;
1208
+ isAutoincrement: false;
1209
+ hasRuntimeDefault: false;
1210
+ enumValues: [string, ...string[]];
1211
+ baseColumn: never;
1212
+ identity: undefined;
1213
+ generated: undefined;
1214
+ }, {}, {}>;
1215
+ operatorId: import("@porulle/core/drizzle").PgColumn<{
1216
+ name: "operator_id";
1217
+ tableName: "pos_operator_pins";
1218
+ dataType: "string";
1219
+ columnType: "PgText";
1220
+ data: string;
1221
+ driverParam: string;
1222
+ notNull: true;
1223
+ hasDefault: false;
1224
+ isPrimaryKey: false;
1225
+ isAutoincrement: false;
1226
+ hasRuntimeDefault: false;
1227
+ enumValues: [string, ...string[]];
1228
+ baseColumn: never;
1229
+ identity: undefined;
1230
+ generated: undefined;
1231
+ }, {}, {}>;
1232
+ pinHash: import("@porulle/core/drizzle").PgColumn<{
1233
+ name: "pin_hash";
1234
+ tableName: "pos_operator_pins";
1235
+ dataType: "string";
1236
+ columnType: "PgText";
1237
+ data: string;
1238
+ driverParam: string;
1239
+ notNull: true;
1240
+ hasDefault: false;
1241
+ isPrimaryKey: false;
1242
+ isAutoincrement: false;
1243
+ hasRuntimeDefault: false;
1244
+ enumValues: [string, ...string[]];
1245
+ baseColumn: never;
1246
+ identity: undefined;
1247
+ generated: undefined;
1248
+ }, {}, {}>;
1249
+ canOverride: import("@porulle/core/drizzle").PgColumn<{
1250
+ name: "can_override";
1251
+ tableName: "pos_operator_pins";
1252
+ dataType: "boolean";
1253
+ columnType: "PgBoolean";
1254
+ data: boolean;
1255
+ driverParam: boolean;
1256
+ notNull: true;
1257
+ hasDefault: true;
1258
+ isPrimaryKey: false;
1259
+ isAutoincrement: false;
1260
+ hasRuntimeDefault: false;
1261
+ enumValues: undefined;
1262
+ baseColumn: never;
1263
+ identity: undefined;
1264
+ generated: undefined;
1265
+ }, {}, {}>;
1266
+ createdAt: import("@porulle/core/drizzle").PgColumn<{
1267
+ name: "created_at";
1268
+ tableName: "pos_operator_pins";
1269
+ dataType: "date";
1270
+ columnType: "PgTimestamp";
1271
+ data: Date;
1272
+ driverParam: string;
1273
+ notNull: true;
1274
+ hasDefault: true;
1275
+ isPrimaryKey: false;
1276
+ isAutoincrement: false;
1277
+ hasRuntimeDefault: false;
1278
+ enumValues: undefined;
1279
+ baseColumn: never;
1280
+ identity: undefined;
1281
+ generated: undefined;
1282
+ }, {}, {}>;
1283
+ updatedAt: import("@porulle/core/drizzle").PgColumn<{
1284
+ name: "updated_at";
1285
+ tableName: "pos_operator_pins";
1286
+ dataType: "date";
1287
+ columnType: "PgTimestamp";
1288
+ data: Date;
1289
+ driverParam: string;
1290
+ notNull: true;
1291
+ hasDefault: true;
1292
+ isPrimaryKey: false;
1293
+ isAutoincrement: false;
1294
+ hasRuntimeDefault: false;
1295
+ enumValues: undefined;
1296
+ baseColumn: never;
1297
+ identity: undefined;
1298
+ generated: undefined;
1299
+ }, {}, {}>;
1300
+ };
1301
+ dialect: "pg";
1302
+ }>;
1177
1303
  export declare const posReturnItems: import("drizzle-orm/pg-core/table").PgTableWithColumns<{
1178
1304
  name: "pos_return_items";
1179
1305
  schema: undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAMH,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAatB,CAAC;AAIJ,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAsBnB,CAAC;AAIJ,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAWvB,CAAC;AAIJ,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA2BzB,CAAC;AAIJ,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAarB,CAAC;AAIJ,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAaxB,CAAC"}
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAMH,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAatB,CAAC;AAIJ,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAsBnB,CAAC;AAIJ,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAWvB,CAAC;AAIJ,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA2BzB,CAAC;AAIJ,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAarB,CAAC;AAIJ,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAYzB,CAAC;AAIJ,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAaxB,CAAC"}
package/dist/schema.js CHANGED
@@ -106,6 +106,20 @@ export const posPayments = pgTable("pos_payments", {
106
106
  }, (table) => ({
107
107
  transactionIdx: index("idx_pos_payments_transaction").on(table.transactionId),
108
108
  }));
109
+ // ─── Operator PINs (issue #51) ──────────────────────────────────────────
110
+ export const posOperatorPins = pgTable("pos_operator_pins", {
111
+ id: uuid("id").defaultRandom().primaryKey(),
112
+ organizationId: text("organization_id").notNull(),
113
+ operatorId: text("operator_id").notNull(),
114
+ // PBKDF2-SHA256, encoded as pbkdf2$<iterations>$<saltB64>$<hashB64>
115
+ pinHash: text("pin_hash").notNull(),
116
+ // Manager override capability — checked by POST /pos/auth/override.
117
+ canOverride: boolean("can_override").notNull().default(false),
118
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
119
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
120
+ }, (table) => ({
121
+ orgOperatorUnique: uniqueIndex("pos_operator_pins_org_operator_unique").on(table.organizationId, table.operatorId),
122
+ }));
109
123
  // ─── Return Items ───────────────────────────────────────────────────────
110
124
  export const posReturnItems = pgTable("pos_return_items", {
111
125
  id: uuid("id").defaultRandom().primaryKey(),
@@ -0,0 +1,55 @@
1
+ import type { PluginResult } from "@porulle/core";
2
+ import type { Db, Transaction } from "../types.js";
3
+ import type { ReturnService } from "./return-service.js";
4
+ import type { TransactionService } from "./transaction-service.js";
5
+ /**
6
+ * Exchanges (issue #53): return line items + create a replacement order +
7
+ * settle the difference, in one call.
8
+ *
9
+ * The financially-critical half — the line-level refund on the original
10
+ * order (core issue #52 primitives: refundable-quantity enforcement, daily
11
+ * cap, audited ledger) and the replacement order creation — runs inside ONE
12
+ * database transaction, so a failure in either leaves no money moved and no
13
+ * order created. The POS bookkeeping rows (transaction, return items) are
14
+ * written after commit and cross-linked for audit.
15
+ */
16
+ export interface ExchangeInput {
17
+ shiftId: string;
18
+ terminalId: string;
19
+ originalOrderId: string;
20
+ currency?: string | undefined;
21
+ customerId?: string | undefined;
22
+ returnItems: Array<{
23
+ originalLineItemId: string;
24
+ quantity: number;
25
+ reason: "defective" | "wrong_item" | "changed_mind" | "other";
26
+ }>;
27
+ replacementItems: Array<{
28
+ entityId: string;
29
+ variantId?: string | undefined;
30
+ sku?: string | undefined;
31
+ title: string;
32
+ quantity: number;
33
+ unitPrice: number;
34
+ taxAmount?: number | undefined;
35
+ }>;
36
+ }
37
+ export declare class ExchangeService {
38
+ private db;
39
+ private services;
40
+ private transaction;
41
+ private transactionService;
42
+ private returnService;
43
+ constructor(db: Db, services: Record<string, unknown>, transaction: <T>(fn: (tx: Db) => Promise<T>) => Promise<T>, transactionService: TransactionService, returnService: ReturnService);
44
+ exchange(orgId: string, input: ExchangeInput, actor: {
45
+ userId: string;
46
+ } & Record<string, unknown>): Promise<PluginResult<{
47
+ transaction: Transaction;
48
+ refundId: string;
49
+ returnTotal: number;
50
+ replacementOrderId: string;
51
+ replacementTotal: number;
52
+ netDelta: number;
53
+ }>>;
54
+ }
55
+ //# sourceMappingURL=exchange-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exchange-service.d.ts","sourceRoot":"","sources":["../../src/services/exchange-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,OAAO,KAAK,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAEnE;;;;;;;;;;GAUG;AAEH,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,WAAW,EAAE,KAAK,CAAC;QACjB,kBAAkB,EAAE,MAAM,CAAC;QAC3B,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,WAAW,GAAG,YAAY,GAAG,cAAc,GAAG,OAAO,CAAC;KAC/D,CAAC,CAAC;IACH,gBAAgB,EAAE,KAAK,CAAC;QACtB,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QAC/B,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QACzB,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,EAAE,MAAM,CAAC;QAClB,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;KAChC,CAAC,CAAC;CACJ;AAgBD,qBAAa,eAAe;IAExB,OAAO,CAAC,EAAE;IACV,OAAO,CAAC,QAAQ;IAChB,OAAO,CAAC,WAAW;IACnB,OAAO,CAAC,kBAAkB;IAC1B,OAAO,CAAC,aAAa;gBAJb,EAAE,EAAE,EAAE,EACN,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,WAAW,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,EAC1D,kBAAkB,EAAE,kBAAkB,EACtC,aAAa,EAAE,aAAa;IAGhC,QAAQ,CACZ,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,aAAa,EACpB,KAAK,EAAE;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAClD,OAAO,CAAC,YAAY,CAAC;QACtB,WAAW,EAAE,WAAW,CAAC;QACzB,QAAQ,EAAE,MAAM,CAAC;QACjB,WAAW,EAAE,MAAM,CAAC;QACpB,kBAAkB,EAAE,MAAM,CAAC;QAC3B,gBAAgB,EAAE,MAAM,CAAC;QACzB,QAAQ,EAAE,MAAM,CAAC;KAClB,CAAC,CAAC;CAsJJ"}
@@ -0,0 +1,145 @@
1
+ import { eq } from "@porulle/core/drizzle";
2
+ import { Ok, Err } from "@porulle/core";
3
+ import { posTransactions } from "../schema.js";
4
+ export class ExchangeService {
5
+ db;
6
+ services;
7
+ transaction;
8
+ transactionService;
9
+ returnService;
10
+ constructor(db, services, transaction, transactionService, returnService) {
11
+ this.db = db;
12
+ this.services = services;
13
+ this.transaction = transaction;
14
+ this.transactionService = transactionService;
15
+ this.returnService = returnService;
16
+ }
17
+ async exchange(orgId, input, actor) {
18
+ if (input.returnItems.length === 0)
19
+ return Err("At least one return item is required");
20
+ if (input.replacementItems.length === 0)
21
+ return Err("At least one replacement item is required");
22
+ const orders = this.services.orders;
23
+ const currency = input.currency ?? "USD";
24
+ const subtotal = input.replacementItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
25
+ const taxTotal = input.replacementItems.reduce((sum, i) => sum + (i.taxAmount ?? 0), 0);
26
+ const replacementTotal = subtotal + taxTotal;
27
+ // Atomic core half: refund the returned lines + create the replacement.
28
+ const core = await this.transaction(async (tx) => {
29
+ const txCtx = { tx, actor };
30
+ const refund = await orders.refundLines(input.originalOrderId, {
31
+ lines: input.returnItems.map((item) => ({
32
+ lineItemId: item.originalLineItemId,
33
+ quantity: item.quantity,
34
+ })),
35
+ reason: "exchange",
36
+ }, actor, txCtx);
37
+ if (!refund.ok || !refund.value) {
38
+ throw new Error(refund.error?.message ?? "Exchange refund failed");
39
+ }
40
+ const replacement = await orders.create({
41
+ currency,
42
+ subtotal,
43
+ taxTotal,
44
+ shippingTotal: 0,
45
+ grandTotal: replacementTotal,
46
+ ...(input.customerId ? { customerId: input.customerId } : {}),
47
+ metadata: {
48
+ exchange: {
49
+ originalOrderId: input.originalOrderId,
50
+ refundId: refund.value.refund.id,
51
+ },
52
+ },
53
+ lineItems: input.replacementItems.map((item) => ({
54
+ entityId: item.entityId,
55
+ entityType: "product",
56
+ ...(item.variantId ? { variantId: item.variantId } : {}),
57
+ ...(item.sku ? { sku: item.sku } : {}),
58
+ title: item.title,
59
+ quantity: item.quantity,
60
+ unitPrice: item.unitPrice,
61
+ totalPrice: item.unitPrice * item.quantity,
62
+ taxAmount: item.taxAmount ?? 0,
63
+ })),
64
+ }, actor, txCtx);
65
+ if (!replacement.ok || !replacement.value) {
66
+ throw new Error(replacement.error?.message ?? "Replacement order creation failed");
67
+ }
68
+ return { refund: refund.value.refund, replacementOrder: replacement.value };
69
+ }).catch((error) => error);
70
+ if (core instanceof Error)
71
+ return Err(core.message);
72
+ const returnTotal = core.refund.amount;
73
+ const netDelta = replacementTotal - returnTotal;
74
+ // POS bookkeeping (post-commit): cart + exchange transaction + return rows.
75
+ const cart = this.services.cart;
76
+ const cartResult = await cart.create({ currency, metadata: { posExchange: true, originalOrderId: input.originalOrderId } }, actor);
77
+ if (!cartResult.ok || !cartResult.value)
78
+ return Err("Failed to create cart for exchange");
79
+ const txnResult = await this.transactionService.create(orgId, {
80
+ shiftId: input.shiftId,
81
+ terminalId: input.terminalId,
82
+ operatorId: actor.userId,
83
+ cartId: cartResult.value.id,
84
+ type: "exchange",
85
+ ...(input.customerId ? { customerId: input.customerId } : {}),
86
+ });
87
+ if (!txnResult.ok)
88
+ return Err(txnResult.error);
89
+ const txn = txnResult.value;
90
+ await this.returnService.addReturnItems(txn.id, input.returnItems.map((item) => ({
91
+ originalOrderId: input.originalOrderId,
92
+ originalLineItemId: item.originalLineItemId,
93
+ quantity: item.quantity,
94
+ reason: item.reason,
95
+ refundAmount: core.refund.lines.find((l) => l.lineItemId === item.originalLineItemId)?.amount ?? 0,
96
+ })));
97
+ await this.transactionService.updateTotals(txn.id, {
98
+ subtotal,
99
+ taxTotal,
100
+ total: netDelta,
101
+ discountTotal: 0,
102
+ });
103
+ // Cross-link everything on the POS transaction for audit.
104
+ await this.db
105
+ .update(posTransactions)
106
+ .set({
107
+ orderId: core.replacementOrder.id,
108
+ metadata: {
109
+ exchange: {
110
+ originalOrderId: input.originalOrderId,
111
+ refundId: core.refund.id,
112
+ replacementOrderId: core.replacementOrder.id,
113
+ returnTotal,
114
+ replacementTotal,
115
+ netDelta,
116
+ },
117
+ },
118
+ updatedAt: new Date(),
119
+ })
120
+ .where(eq(posTransactions.id, txn.id));
121
+ // An even exchange settles itself — nothing to tender.
122
+ let finalTxn = txn;
123
+ if (netDelta === 0) {
124
+ const completed = await this.transactionService.complete(txn.id, core.replacementOrder.id);
125
+ if (completed.ok)
126
+ finalTxn = completed.value;
127
+ }
128
+ else {
129
+ const rows = await this.db
130
+ .select()
131
+ .from(posTransactions)
132
+ .where(eq(posTransactions.id, txn.id));
133
+ if (rows[0])
134
+ finalTxn = rows[0];
135
+ }
136
+ return Ok({
137
+ transaction: finalTxn,
138
+ refundId: core.refund.id,
139
+ returnTotal,
140
+ replacementOrderId: core.replacementOrder.id,
141
+ replacementTotal,
142
+ netDelta,
143
+ });
144
+ }
145
+ }