@porulle/plugin-pos 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hooks/checkout-pos.d.ts.map +1 -1
- package/dist/hooks/checkout-pos.js +9 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -1
- package/dist/routes/auth.d.ts +19 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +66 -0
- package/dist/routes/exchanges.d.ts +15 -0
- package/dist/routes/exchanges.d.ts.map +1 -0
- package/dist/routes/exchanges.js +42 -0
- package/dist/routes/returns.d.ts.map +1 -1
- package/dist/routes/returns.js +25 -4
- package/dist/schema.d.ts +235 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +25 -0
- package/dist/services/exchange-service.d.ts +55 -0
- package/dist/services/exchange-service.d.ts.map +1 -0
- package/dist/services/exchange-service.js +168 -0
- package/dist/services/pin-service.d.ts +73 -0
- package/dist/services/pin-service.d.ts.map +1 -0
- package/dist/services/pin-service.js +269 -0
- package/dist/services/transaction-service.d.ts.map +1 -1
- package/dist/services/transaction-service.js +5 -5
- package/dist/types.d.ts +16 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -0
- package/package.json +3 -3
- package/src/hooks/checkout-pos.ts +9 -2
- package/src/index.ts +33 -0
- package/src/routes/auth.ts +72 -0
- package/src/routes/exchanges.ts +53 -0
- package/src/routes/returns.ts +47 -5
- package/src/schema.ts +29 -0
- package/src/services/exchange-service.ts +272 -0
- package/src/services/pin-service.ts +350 -0
- package/src/services/transaction-service.ts +5 -4
- package/src/types.ts +20 -0
package/dist/types.d.ts.map
CHANGED
|
@@ -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;
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-pos",
|
|
3
|
-
"version": "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.
|
|
23
|
-
"@porulle/db": "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
|
+
}
|
package/src/routes/returns.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
+
}
|