@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
|
@@ -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;AA8BD,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;CAqLJ"}
|
|
@@ -0,0 +1,168 @@
|
|
|
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 pricing = this.services.pricing;
|
|
24
|
+
const currency = input.currency ?? "USD";
|
|
25
|
+
// Atomic core half: refund the returned lines + create the replacement.
|
|
26
|
+
const core = await this.transaction(async (tx) => {
|
|
27
|
+
const txCtx = { tx, actor };
|
|
28
|
+
const pricedReplacements = [];
|
|
29
|
+
for (const item of input.replacementItems) {
|
|
30
|
+
const resolved = await pricing.resolve({
|
|
31
|
+
entityId: item.entityId,
|
|
32
|
+
currency,
|
|
33
|
+
quantity: item.quantity,
|
|
34
|
+
...(item.variantId ? { variantId: item.variantId } : {}),
|
|
35
|
+
}, actor, txCtx);
|
|
36
|
+
if (!resolved.ok || !resolved.value) {
|
|
37
|
+
throw new Error(resolved.error?.message ?? `Cannot resolve price for replacement item ${item.entityId}`);
|
|
38
|
+
}
|
|
39
|
+
pricedReplacements.push({ ...item, unitPrice: resolved.value.finalAmount });
|
|
40
|
+
}
|
|
41
|
+
const subtotal = pricedReplacements.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
|
|
42
|
+
const taxTotal = pricedReplacements.reduce((sum, i) => sum + (i.taxAmount ?? 0), 0);
|
|
43
|
+
const replacementTotal = subtotal + taxTotal;
|
|
44
|
+
const refund = await orders.refundLines(input.originalOrderId, {
|
|
45
|
+
lines: input.returnItems.map((item) => ({
|
|
46
|
+
lineItemId: item.originalLineItemId,
|
|
47
|
+
quantity: item.quantity,
|
|
48
|
+
})),
|
|
49
|
+
reason: "exchange",
|
|
50
|
+
}, actor, txCtx);
|
|
51
|
+
if (!refund.ok || !refund.value) {
|
|
52
|
+
throw new Error(refund.error?.message ?? "Exchange refund failed");
|
|
53
|
+
}
|
|
54
|
+
const replacement = await orders.create({
|
|
55
|
+
currency,
|
|
56
|
+
subtotal,
|
|
57
|
+
taxTotal,
|
|
58
|
+
shippingTotal: 0,
|
|
59
|
+
grandTotal: replacementTotal,
|
|
60
|
+
...(input.customerId ? { customerId: input.customerId } : {}),
|
|
61
|
+
metadata: {
|
|
62
|
+
exchange: {
|
|
63
|
+
originalOrderId: input.originalOrderId,
|
|
64
|
+
refundId: refund.value.refund.id,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
lineItems: pricedReplacements.map((item) => ({
|
|
68
|
+
entityId: item.entityId,
|
|
69
|
+
entityType: "product",
|
|
70
|
+
...(item.variantId ? { variantId: item.variantId } : {}),
|
|
71
|
+
...(item.sku ? { sku: item.sku } : {}),
|
|
72
|
+
title: item.title,
|
|
73
|
+
quantity: item.quantity,
|
|
74
|
+
unitPrice: item.unitPrice,
|
|
75
|
+
totalPrice: item.unitPrice * item.quantity,
|
|
76
|
+
taxAmount: item.taxAmount ?? 0,
|
|
77
|
+
})),
|
|
78
|
+
}, actor, txCtx,
|
|
79
|
+
// Prices are server-resolved above (pricing.resolve); reserve stock for
|
|
80
|
+
// the replacement items so an exchange cannot oversell.
|
|
81
|
+
{ trustedPricing: true, stockPolicy: "reserve" });
|
|
82
|
+
if (!replacement.ok || !replacement.value) {
|
|
83
|
+
throw new Error(replacement.error?.message ?? "Replacement order creation failed");
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
refund: refund.value.refund,
|
|
87
|
+
replacementOrder: replacement.value,
|
|
88
|
+
subtotal,
|
|
89
|
+
taxTotal,
|
|
90
|
+
replacementTotal,
|
|
91
|
+
};
|
|
92
|
+
}).catch((error) => error);
|
|
93
|
+
if (core instanceof Error)
|
|
94
|
+
return Err(core.message);
|
|
95
|
+
const returnTotal = core.refund.amount;
|
|
96
|
+
const netDelta = core.replacementTotal - returnTotal;
|
|
97
|
+
// POS bookkeeping (post-commit): cart + exchange transaction + return rows.
|
|
98
|
+
const cart = this.services.cart;
|
|
99
|
+
const cartResult = await cart.create({ currency, metadata: { posExchange: true, originalOrderId: input.originalOrderId } }, actor);
|
|
100
|
+
if (!cartResult.ok || !cartResult.value)
|
|
101
|
+
return Err("Failed to create cart for exchange");
|
|
102
|
+
const txnResult = await this.transactionService.create(orgId, {
|
|
103
|
+
shiftId: input.shiftId,
|
|
104
|
+
terminalId: input.terminalId,
|
|
105
|
+
operatorId: actor.userId,
|
|
106
|
+
cartId: cartResult.value.id,
|
|
107
|
+
type: "exchange",
|
|
108
|
+
...(input.customerId ? { customerId: input.customerId } : {}),
|
|
109
|
+
});
|
|
110
|
+
if (!txnResult.ok)
|
|
111
|
+
return Err(txnResult.error);
|
|
112
|
+
const txn = txnResult.value;
|
|
113
|
+
await this.returnService.addReturnItems(txn.id, input.returnItems.map((item) => ({
|
|
114
|
+
originalOrderId: input.originalOrderId,
|
|
115
|
+
originalLineItemId: item.originalLineItemId,
|
|
116
|
+
quantity: item.quantity,
|
|
117
|
+
reason: item.reason,
|
|
118
|
+
refundAmount: core.refund.lines.find((l) => l.lineItemId === item.originalLineItemId)?.amount ?? 0,
|
|
119
|
+
})));
|
|
120
|
+
await this.transactionService.updateTotals(txn.id, {
|
|
121
|
+
subtotal: core.subtotal,
|
|
122
|
+
taxTotal: core.taxTotal,
|
|
123
|
+
total: netDelta,
|
|
124
|
+
discountTotal: 0,
|
|
125
|
+
});
|
|
126
|
+
// Cross-link everything on the POS transaction for audit.
|
|
127
|
+
await this.db
|
|
128
|
+
.update(posTransactions)
|
|
129
|
+
.set({
|
|
130
|
+
orderId: core.replacementOrder.id,
|
|
131
|
+
metadata: {
|
|
132
|
+
exchange: {
|
|
133
|
+
originalOrderId: input.originalOrderId,
|
|
134
|
+
refundId: core.refund.id,
|
|
135
|
+
replacementOrderId: core.replacementOrder.id,
|
|
136
|
+
returnTotal,
|
|
137
|
+
replacementTotal: core.replacementTotal,
|
|
138
|
+
netDelta,
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
updatedAt: new Date(),
|
|
142
|
+
})
|
|
143
|
+
.where(eq(posTransactions.id, txn.id));
|
|
144
|
+
// An even exchange settles itself — nothing to tender.
|
|
145
|
+
let finalTxn = txn;
|
|
146
|
+
if (netDelta === 0) {
|
|
147
|
+
const completed = await this.transactionService.complete(txn.id, core.replacementOrder.id);
|
|
148
|
+
if (completed.ok)
|
|
149
|
+
finalTxn = completed.value;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
const rows = await this.db
|
|
153
|
+
.select()
|
|
154
|
+
.from(posTransactions)
|
|
155
|
+
.where(eq(posTransactions.id, txn.id));
|
|
156
|
+
if (rows[0])
|
|
157
|
+
finalTxn = rows[0];
|
|
158
|
+
}
|
|
159
|
+
return Ok({
|
|
160
|
+
transaction: finalTxn,
|
|
161
|
+
refundId: core.refund.id,
|
|
162
|
+
returnTotal,
|
|
163
|
+
replacementOrderId: core.replacementOrder.id,
|
|
164
|
+
replacementTotal: core.replacementTotal,
|
|
165
|
+
netDelta,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { PluginResult } from "@porulle/core";
|
|
2
|
+
import type { Db } from "../types.js";
|
|
3
|
+
export declare const PIN_LOCKOUT_ERROR = "Too many failed PIN attempts; try again later";
|
|
4
|
+
/** Narrow Better Auth surface the PIN runtime uses. */
|
|
5
|
+
export interface PinAuthApi {
|
|
6
|
+
api: {
|
|
7
|
+
createApiKey(input: {
|
|
8
|
+
body: Record<string, unknown>;
|
|
9
|
+
}): Promise<unknown>;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export declare function hashPin(pin: string): Promise<string>;
|
|
13
|
+
export declare function verifyPinHash(pin: string, encoded: string): Promise<boolean>;
|
|
14
|
+
export declare class PinService {
|
|
15
|
+
private db;
|
|
16
|
+
private options;
|
|
17
|
+
constructor(db: Db, options?: {
|
|
18
|
+
/** Named auth.apiKeyScopes config used to mint shift keys (optional). */
|
|
19
|
+
apiKeyScope?: string | undefined;
|
|
20
|
+
/** Shift-credential lifetime in seconds. Default: 12h. */
|
|
21
|
+
credentialTtlSeconds?: number | undefined;
|
|
22
|
+
/** Failed PIN attempts before lockout (SEC-15). Default: 5. */
|
|
23
|
+
lockoutMaxAttempts?: number | undefined;
|
|
24
|
+
/** Failure window and lockout duration in minutes (SEC-15). Default: 15. */
|
|
25
|
+
lockoutWindowMinutes?: number | undefined;
|
|
26
|
+
});
|
|
27
|
+
private lockoutMaxAttempts;
|
|
28
|
+
private lockoutWindowMs;
|
|
29
|
+
private getPinAttempt;
|
|
30
|
+
private isLockedOut;
|
|
31
|
+
private recordFailedPinAttempt;
|
|
32
|
+
private clearPinAttempts;
|
|
33
|
+
setPin(orgId: string, input: {
|
|
34
|
+
operatorId: string;
|
|
35
|
+
pin: string;
|
|
36
|
+
canOverride?: boolean | undefined;
|
|
37
|
+
}): Promise<PluginResult<{
|
|
38
|
+
operatorId: string;
|
|
39
|
+
canOverride: boolean;
|
|
40
|
+
}>>;
|
|
41
|
+
private verifyOperatorPin;
|
|
42
|
+
/**
|
|
43
|
+
* PIN login: verifies the PIN, resolves the operator's open shift, and
|
|
44
|
+
* mints a short-lived API key bound to it. The terminal's device key
|
|
45
|
+
* authenticates this call (pos:operate); the returned key is personal.
|
|
46
|
+
*/
|
|
47
|
+
pinLogin(orgId: string, input: {
|
|
48
|
+
operatorId: string;
|
|
49
|
+
pin: string;
|
|
50
|
+
shiftId?: string | undefined;
|
|
51
|
+
}, auth: PinAuthApi | undefined): Promise<PluginResult<{
|
|
52
|
+
operatorId: string;
|
|
53
|
+
shiftId: string;
|
|
54
|
+
terminalId: string;
|
|
55
|
+
apiKey: string;
|
|
56
|
+
expiresAt: string;
|
|
57
|
+
}>>;
|
|
58
|
+
/**
|
|
59
|
+
* Manager override: verifies the manager's PIN (record must carry
|
|
60
|
+
* canOverride) and returns an approval envelope for the requested action.
|
|
61
|
+
*/
|
|
62
|
+
override(orgId: string, input: {
|
|
63
|
+
operatorId: string;
|
|
64
|
+
pin: string;
|
|
65
|
+
action: string;
|
|
66
|
+
}): Promise<PluginResult<{
|
|
67
|
+
approved: true;
|
|
68
|
+
operatorId: string;
|
|
69
|
+
action: string;
|
|
70
|
+
approvedAt: string;
|
|
71
|
+
}>>;
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=pin-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pin-service.d.ts","sourceRoot":"","sources":["../../src/services/pin-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAGlD,OAAO,KAAK,EAAE,EAAE,EAAkC,MAAM,aAAa,CAAC;AAgBtE,eAAO,MAAM,iBAAiB,kDAAkD,CAAC;AAOjF,uDAAuD;AACvD,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE;QACH,YAAY,CAAC,KAAK,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;SAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;KAC1E,CAAC;CACH;AA+BD,wBAAsB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAI1D;AAED,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CASlF;AAmBD,qBAAa,UAAU;IAEnB,OAAO,CAAC,EAAE;IACV,OAAO,CAAC,OAAO;gBADP,EAAE,EAAE,EAAE,EACN,OAAO,GAAE;QACf,yEAAyE;QACzE,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QACjC,0DAA0D;QAC1D,oBAAoB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QAC1C,+DAA+D;QAC/D,kBAAkB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QACxC,4EAA4E;QAC5E,oBAAoB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;KACtC;IAGR,OAAO,CAAC,kBAAkB;IAI1B,OAAO,CAAC,eAAe;YAKT,aAAa;IAc3B,OAAO,CAAC,WAAW;YAIL,sBAAsB;YA+BtB,gBAAgB;IASxB,MAAM,CACV,KAAK,EAAE,MAAM,EACb,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;KAAE,GAC5E,OAAO,CAAC,YAAY,CAAC;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;YAgCxD,iBAAiB;IA+B/B;;;;OAIG;IACG,QAAQ,CACZ,KAAK,EAAE,MAAM,EACb,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,EACxE,IAAI,EAAE,UAAU,GAAG,SAAS,GAC3B,OAAO,CAAC,YAAY,CAAC;QACtB,UAAU,EAAE,MAAM,CAAC;QACnB,OAAO,EAAE,MAAM,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,MAAM,EAAE,MAAM,CAAC;QACf,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC,CAAC;IAgEH;;;OAGG;IACG,QAAQ,CACZ,KAAK,EAAE,MAAM,EACb,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GACzD,OAAO,CAAC,YAAY,CAAC;QACtB,QAAQ,EAAE,IAAI,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;QACnB,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;KACpB,CAAC,CAAC;CAYJ"}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { eq, and } from "@porulle/core/drizzle";
|
|
2
|
+
import { Ok, Err } from "@porulle/core";
|
|
3
|
+
import { user } from "@porulle/core/auth-schema";
|
|
4
|
+
import { posOperatorPins, posPinAttempts, posShifts } from "../schema.js";
|
|
5
|
+
/**
|
|
6
|
+
* PIN auth runtime (issue #51).
|
|
7
|
+
*
|
|
8
|
+
* PINs hash with PBKDF2-SHA256 via Web Crypto (Workers-safe, no Node-only
|
|
9
|
+
* APIs). PIN login verifies the operator's PIN against their open shift and
|
|
10
|
+
* mints a short-lived Better Auth API key scoped to POS operation — the
|
|
11
|
+
* per-shift credential POS apps previously hand-rolled through module-global
|
|
12
|
+
* auth holders. Manager override verifies a PIN whose record carries
|
|
13
|
+
* `canOverride` and returns an audited approval.
|
|
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";
|
|
19
|
+
function toBase64(bytes) {
|
|
20
|
+
let binary = "";
|
|
21
|
+
for (const b of bytes)
|
|
22
|
+
binary += String.fromCharCode(b);
|
|
23
|
+
return btoa(binary);
|
|
24
|
+
}
|
|
25
|
+
function fromBase64(value) {
|
|
26
|
+
const binary = atob(value);
|
|
27
|
+
const bytes = new Uint8Array(binary.length);
|
|
28
|
+
for (let i = 0; i < binary.length; i++)
|
|
29
|
+
bytes[i] = binary.charCodeAt(i);
|
|
30
|
+
return bytes;
|
|
31
|
+
}
|
|
32
|
+
async function deriveHash(pin, salt, iterations) {
|
|
33
|
+
const keyMaterial = await crypto.subtle.importKey("raw", new TextEncoder().encode(pin), "PBKDF2", false, ["deriveBits"]);
|
|
34
|
+
const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: salt.slice().buffer, iterations }, keyMaterial, 256);
|
|
35
|
+
return new Uint8Array(bits);
|
|
36
|
+
}
|
|
37
|
+
export async function hashPin(pin) {
|
|
38
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
39
|
+
const hash = await deriveHash(pin, salt, PBKDF2_ITERATIONS);
|
|
40
|
+
return `pbkdf2$${PBKDF2_ITERATIONS}$${toBase64(salt)}$${toBase64(hash)}`;
|
|
41
|
+
}
|
|
42
|
+
export async function verifyPinHash(pin, encoded) {
|
|
43
|
+
const [scheme, iterationsRaw, saltB64, hashB64] = encoded.split("$");
|
|
44
|
+
if (scheme !== "pbkdf2" || !iterationsRaw || !saltB64 || !hashB64)
|
|
45
|
+
return false;
|
|
46
|
+
const expected = fromBase64(hashB64);
|
|
47
|
+
const actual = await deriveHash(pin, fromBase64(saltB64), Number(iterationsRaw));
|
|
48
|
+
if (actual.length !== expected.length)
|
|
49
|
+
return false;
|
|
50
|
+
let diff = 0;
|
|
51
|
+
for (let i = 0; i < actual.length; i++)
|
|
52
|
+
diff |= actual[i] ^ expected[i];
|
|
53
|
+
return diff === 0;
|
|
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
|
+
}
|
|
71
|
+
export class PinService {
|
|
72
|
+
db;
|
|
73
|
+
options;
|
|
74
|
+
constructor(db, options = {}) {
|
|
75
|
+
this.db = db;
|
|
76
|
+
this.options = options;
|
|
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
|
+
}
|
|
132
|
+
async setPin(orgId, input) {
|
|
133
|
+
if (!/^\d{4,8}$/.test(input.pin)) {
|
|
134
|
+
return Err("PIN must be 4-8 digits");
|
|
135
|
+
}
|
|
136
|
+
const pinHash = await hashPin(input.pin);
|
|
137
|
+
const existing = await this.db
|
|
138
|
+
.select()
|
|
139
|
+
.from(posOperatorPins)
|
|
140
|
+
.where(and(eq(posOperatorPins.organizationId, orgId), eq(posOperatorPins.operatorId, input.operatorId)));
|
|
141
|
+
if (existing.length > 0) {
|
|
142
|
+
await this.db
|
|
143
|
+
.update(posOperatorPins)
|
|
144
|
+
.set({
|
|
145
|
+
pinHash,
|
|
146
|
+
canOverride: input.canOverride ?? existing[0].canOverride,
|
|
147
|
+
updatedAt: new Date(),
|
|
148
|
+
})
|
|
149
|
+
.where(eq(posOperatorPins.id, existing[0].id));
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
await this.db.insert(posOperatorPins).values({
|
|
153
|
+
organizationId: orgId,
|
|
154
|
+
operatorId: input.operatorId,
|
|
155
|
+
pinHash,
|
|
156
|
+
canOverride: input.canOverride ?? false,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return Ok({ operatorId: input.operatorId, canOverride: input.canOverride ?? false });
|
|
160
|
+
}
|
|
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" };
|
|
166
|
+
const rows = await this.db
|
|
167
|
+
.select()
|
|
168
|
+
.from(posOperatorPins)
|
|
169
|
+
.where(and(eq(posOperatorPins.organizationId, orgId), eq(posOperatorPins.operatorId, operatorId)));
|
|
170
|
+
const record = rows[0];
|
|
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 };
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* PIN login: verifies the PIN, resolves the operator's open shift, and
|
|
184
|
+
* mints a short-lived API key bound to it. The terminal's device key
|
|
185
|
+
* authenticates this call (pos:operate); the returned key is personal.
|
|
186
|
+
*/
|
|
187
|
+
async pinLogin(orgId, input, auth) {
|
|
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")
|
|
192
|
+
return Err("Invalid operator or PIN");
|
|
193
|
+
const conditions = [
|
|
194
|
+
eq(posShifts.organizationId, orgId),
|
|
195
|
+
eq(posShifts.operatorId, input.operatorId),
|
|
196
|
+
eq(posShifts.status, "open"),
|
|
197
|
+
];
|
|
198
|
+
if (input.shiftId)
|
|
199
|
+
conditions.push(eq(posShifts.id, input.shiftId));
|
|
200
|
+
const shifts = await this.db.select().from(posShifts).where(and(...conditions));
|
|
201
|
+
const shift = shifts[0];
|
|
202
|
+
if (!shift)
|
|
203
|
+
return Err("No open shift for this operator — open a shift first");
|
|
204
|
+
if (!auth) {
|
|
205
|
+
return Err("PIN login requires the Better Auth instance (plugin ctx.auth) — are routes mounted by createServer?");
|
|
206
|
+
}
|
|
207
|
+
await ensureOperatorUser(this.db, input.operatorId);
|
|
208
|
+
const ttlSeconds = this.options.credentialTtlSeconds ?? 12 * 3600;
|
|
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
|
+
}
|
|
232
|
+
if (!created?.key)
|
|
233
|
+
return Err("Failed to mint shift credential");
|
|
234
|
+
// Record the credential on the shift so admins can trace/revoke it.
|
|
235
|
+
await this.db
|
|
236
|
+
.update(posShifts)
|
|
237
|
+
.set({
|
|
238
|
+
metadata: { ...(shift.metadata ?? {}), pinLoginApiKeyId: created.id ?? null },
|
|
239
|
+
updatedAt: new Date(),
|
|
240
|
+
})
|
|
241
|
+
.where(eq(posShifts.id, shift.id));
|
|
242
|
+
return Ok({
|
|
243
|
+
operatorId: input.operatorId,
|
|
244
|
+
shiftId: shift.id,
|
|
245
|
+
terminalId: shift.terminalId,
|
|
246
|
+
apiKey: created.key,
|
|
247
|
+
expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Manager override: verifies the manager's PIN (record must carry
|
|
252
|
+
* canOverride) and returns an approval envelope for the requested action.
|
|
253
|
+
*/
|
|
254
|
+
async override(orgId, input) {
|
|
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")
|
|
259
|
+
return Err("Invalid operator or PIN");
|
|
260
|
+
if (!verified.record.canOverride)
|
|
261
|
+
return Err("This operator cannot approve overrides");
|
|
262
|
+
return Ok({
|
|
263
|
+
approved: true,
|
|
264
|
+
operatorId: input.operatorId,
|
|
265
|
+
action: input.action,
|
|
266
|
+
approvedAt: new Date().toISOString(),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -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 } 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;
|
|
@@ -12,6 +12,10 @@ export type Payment = typeof posPayments.$inferSelect;
|
|
|
12
12
|
export type PaymentInsert = typeof posPayments.$inferInsert;
|
|
13
13
|
export type ReturnItem = typeof posReturnItems.$inferSelect;
|
|
14
14
|
export type ReturnItemInsert = typeof posReturnItems.$inferInsert;
|
|
15
|
+
export type OperatorPin = typeof posOperatorPins.$inferSelect;
|
|
16
|
+
export type OperatorPinInsert = typeof posOperatorPins.$inferInsert;
|
|
17
|
+
export type PinAttempt = typeof posPinAttempts.$inferSelect;
|
|
18
|
+
export type PinAttemptInsert = typeof posPinAttempts.$inferInsert;
|
|
15
19
|
export type TransactionStatus = "open" | "held" | "completed" | "voided";
|
|
16
20
|
export type TransactionType = "sale" | "return" | "exchange";
|
|
17
21
|
export type PaymentMethod = "cash" | "card" | "gift_card" | "store_credit" | "other";
|
|
@@ -25,6 +29,17 @@ export interface POSPluginOptions {
|
|
|
25
29
|
maxHoldHours?: number;
|
|
26
30
|
/** Require manager override for discounts above this percentage. Default: 20 */
|
|
27
31
|
discountOverrideThreshold?: number;
|
|
32
|
+
/** PIN auth runtime (issue #51). */
|
|
33
|
+
pinAuth?: {
|
|
34
|
+
/** Named auth.apiKeyScopes config used to mint per-shift keys. */
|
|
35
|
+
apiKeyScope?: string;
|
|
36
|
+
/** Shift-credential lifetime in seconds. Default: 43200 (12h). */
|
|
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;
|
|
42
|
+
};
|
|
28
43
|
}
|
|
29
44
|
export declare const DEFAULT_POS_OPTIONS: Required<POSPluginOptions>;
|
|
30
45
|
//# sourceMappingURL=types.d.ts.map
|