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