@porulle/plugin-pos 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,227 @@
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 CoreOrdersService {
44
+ refundLines(
45
+ orderId: string,
46
+ input: { lines: Array<{ lineItemId: string; quantity: number }>; reason?: string },
47
+ actor: unknown,
48
+ ctx?: unknown,
49
+ ): Promise<{ ok: boolean; value?: { refund: { id: string; amount: number; lines: Array<{ lineItemId: string; quantity: number; amount: number }> } }; error?: { message?: string } }>;
50
+ create(
51
+ input: Record<string, unknown>,
52
+ actor: unknown,
53
+ ctx?: unknown,
54
+ ): Promise<{ ok: boolean; value?: { id: string; grandTotal: number }; error?: { message?: string } }>;
55
+ }
56
+
57
+ export class ExchangeService {
58
+ constructor(
59
+ private db: Db,
60
+ private services: Record<string, unknown>,
61
+ private transaction: <T>(fn: (tx: Db) => Promise<T>) => Promise<T>,
62
+ private transactionService: TransactionService,
63
+ private returnService: ReturnService,
64
+ ) {}
65
+
66
+ async exchange(
67
+ orgId: string,
68
+ input: ExchangeInput,
69
+ actor: { userId: string } & Record<string, unknown>,
70
+ ): Promise<PluginResult<{
71
+ transaction: Transaction;
72
+ refundId: string;
73
+ returnTotal: number;
74
+ replacementOrderId: string;
75
+ replacementTotal: number;
76
+ netDelta: number;
77
+ }>> {
78
+ if (input.returnItems.length === 0) return Err("At least one return item is required");
79
+ if (input.replacementItems.length === 0) return Err("At least one replacement item is required");
80
+
81
+ const orders = this.services.orders as CoreOrdersService;
82
+ const currency = input.currency ?? "USD";
83
+
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
+ // Atomic core half: refund the returned lines + create the replacement.
89
+ const core = await this.transaction(async (tx) => {
90
+ const txCtx = { tx, actor };
91
+ const refund = await orders.refundLines(
92
+ input.originalOrderId,
93
+ {
94
+ lines: input.returnItems.map((item) => ({
95
+ lineItemId: item.originalLineItemId,
96
+ quantity: item.quantity,
97
+ })),
98
+ reason: "exchange",
99
+ },
100
+ actor,
101
+ txCtx,
102
+ );
103
+ if (!refund.ok || !refund.value) {
104
+ throw new Error(refund.error?.message ?? "Exchange refund failed");
105
+ }
106
+
107
+ const replacement = await orders.create(
108
+ {
109
+ currency,
110
+ subtotal,
111
+ taxTotal,
112
+ shippingTotal: 0,
113
+ grandTotal: replacementTotal,
114
+ ...(input.customerId ? { customerId: input.customerId } : {}),
115
+ metadata: {
116
+ exchange: {
117
+ originalOrderId: input.originalOrderId,
118
+ refundId: refund.value.refund.id,
119
+ },
120
+ },
121
+ lineItems: input.replacementItems.map((item) => ({
122
+ entityId: item.entityId,
123
+ entityType: "product",
124
+ ...(item.variantId ? { variantId: item.variantId } : {}),
125
+ ...(item.sku ? { sku: item.sku } : {}),
126
+ title: item.title,
127
+ quantity: item.quantity,
128
+ unitPrice: item.unitPrice,
129
+ totalPrice: item.unitPrice * item.quantity,
130
+ taxAmount: item.taxAmount ?? 0,
131
+ })),
132
+ },
133
+ actor,
134
+ txCtx,
135
+ );
136
+ if (!replacement.ok || !replacement.value) {
137
+ throw new Error(replacement.error?.message ?? "Replacement order creation failed");
138
+ }
139
+ return { refund: refund.value.refund, replacementOrder: replacement.value };
140
+ }).catch((error: unknown) => error as Error);
141
+
142
+ if (core instanceof Error) return Err(core.message);
143
+
144
+ const returnTotal = core.refund.amount;
145
+ const netDelta = replacementTotal - returnTotal;
146
+
147
+ // POS bookkeeping (post-commit): cart + exchange transaction + return rows.
148
+ const cart = this.services.cart as {
149
+ create(input: Record<string, unknown>, actor: unknown): Promise<{ ok: boolean; value?: { id: string } }>;
150
+ };
151
+ const cartResult = await cart.create(
152
+ { currency, metadata: { posExchange: true, originalOrderId: input.originalOrderId } },
153
+ actor,
154
+ );
155
+ if (!cartResult.ok || !cartResult.value) return Err("Failed to create cart for exchange");
156
+
157
+ const txnResult = await this.transactionService.create(orgId, {
158
+ shiftId: input.shiftId,
159
+ terminalId: input.terminalId,
160
+ operatorId: actor.userId,
161
+ cartId: cartResult.value.id,
162
+ type: "exchange",
163
+ ...(input.customerId ? { customerId: input.customerId } : {}),
164
+ });
165
+ if (!txnResult.ok) return Err(txnResult.error);
166
+ const txn = txnResult.value;
167
+
168
+ await this.returnService.addReturnItems(
169
+ txn.id,
170
+ input.returnItems.map((item) => ({
171
+ originalOrderId: input.originalOrderId,
172
+ originalLineItemId: item.originalLineItemId,
173
+ quantity: item.quantity,
174
+ reason: item.reason,
175
+ refundAmount:
176
+ core.refund.lines.find((l) => l.lineItemId === item.originalLineItemId)?.amount ?? 0,
177
+ })),
178
+ );
179
+
180
+ await this.transactionService.updateTotals(txn.id, {
181
+ subtotal,
182
+ taxTotal,
183
+ total: netDelta,
184
+ discountTotal: 0,
185
+ });
186
+ // Cross-link everything on the POS transaction for audit.
187
+ await this.db
188
+ .update(posTransactions)
189
+ .set({
190
+ orderId: core.replacementOrder.id,
191
+ metadata: {
192
+ exchange: {
193
+ originalOrderId: input.originalOrderId,
194
+ refundId: core.refund.id,
195
+ replacementOrderId: core.replacementOrder.id,
196
+ returnTotal,
197
+ replacementTotal,
198
+ netDelta,
199
+ },
200
+ },
201
+ updatedAt: new Date(),
202
+ })
203
+ .where(eq(posTransactions.id, txn.id));
204
+
205
+ // An even exchange settles itself — nothing to tender.
206
+ let finalTxn = txn;
207
+ if (netDelta === 0) {
208
+ const completed = await this.transactionService.complete(txn.id, core.replacementOrder.id);
209
+ if (completed.ok) finalTxn = completed.value;
210
+ } else {
211
+ const rows = await this.db
212
+ .select()
213
+ .from(posTransactions)
214
+ .where(eq(posTransactions.id, txn.id));
215
+ if (rows[0]) finalTxn = rows[0] as Transaction;
216
+ }
217
+
218
+ return Ok({
219
+ transaction: finalTxn,
220
+ refundId: core.refund.id,
221
+ returnTotal,
222
+ replacementOrderId: core.replacementOrder.id,
223
+ replacementTotal,
224
+ netDelta,
225
+ });
226
+ }
227
+ }
@@ -0,0 +1,228 @@
1
+ import { eq, and } from "@porulle/core/drizzle";
2
+ import { Ok, Err } from "@porulle/core";
3
+ import type { PluginResult } from "@porulle/core";
4
+ import { posOperatorPins, posShifts } from "../schema.js";
5
+ import type { Db, OperatorPin, Shift } from "../types.js";
6
+
7
+ /**
8
+ * PIN auth runtime (issue #51).
9
+ *
10
+ * PINs hash with PBKDF2-SHA256 via Web Crypto (Workers-safe, no Node-only
11
+ * APIs). PIN login verifies the operator's PIN against their open shift and
12
+ * mints a short-lived Better Auth API key scoped to POS operation — the
13
+ * per-shift credential POS apps previously hand-rolled through module-global
14
+ * auth holders. Manager override verifies a PIN whose record carries
15
+ * `canOverride` and returns an audited approval.
16
+ */
17
+
18
+ const PBKDF2_ITERATIONS = 100_000;
19
+
20
+ /** Narrow Better Auth surface the PIN runtime uses. */
21
+ export interface PinAuthApi {
22
+ api: {
23
+ createApiKey(input: { body: Record<string, unknown> }): Promise<unknown>;
24
+ };
25
+ }
26
+
27
+ function toBase64(bytes: Uint8Array): string {
28
+ let binary = "";
29
+ for (const b of bytes) binary += String.fromCharCode(b);
30
+ return btoa(binary);
31
+ }
32
+
33
+ function fromBase64(value: string): Uint8Array {
34
+ const binary = atob(value);
35
+ const bytes = new Uint8Array(binary.length);
36
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
37
+ return bytes;
38
+ }
39
+
40
+ async function deriveHash(pin: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
41
+ const keyMaterial = await crypto.subtle.importKey(
42
+ "raw",
43
+ new TextEncoder().encode(pin),
44
+ "PBKDF2",
45
+ false,
46
+ ["deriveBits"],
47
+ );
48
+ const bits = await crypto.subtle.deriveBits(
49
+ { name: "PBKDF2", hash: "SHA-256", salt: salt.slice().buffer as ArrayBuffer, iterations },
50
+ keyMaterial,
51
+ 256,
52
+ );
53
+ return new Uint8Array(bits);
54
+ }
55
+
56
+ export async function hashPin(pin: string): Promise<string> {
57
+ const salt = crypto.getRandomValues(new Uint8Array(16));
58
+ const hash = await deriveHash(pin, salt, PBKDF2_ITERATIONS);
59
+ return `pbkdf2$${PBKDF2_ITERATIONS}$${toBase64(salt)}$${toBase64(hash)}`;
60
+ }
61
+
62
+ export async function verifyPinHash(pin: string, encoded: string): Promise<boolean> {
63
+ const [scheme, iterationsRaw, saltB64, hashB64] = encoded.split("$");
64
+ if (scheme !== "pbkdf2" || !iterationsRaw || !saltB64 || !hashB64) return false;
65
+ const expected = fromBase64(hashB64);
66
+ const actual = await deriveHash(pin, fromBase64(saltB64), Number(iterationsRaw));
67
+ if (actual.length !== expected.length) return false;
68
+ let diff = 0;
69
+ for (let i = 0; i < actual.length; i++) diff |= actual[i]! ^ expected[i]!;
70
+ return diff === 0;
71
+ }
72
+
73
+ export class PinService {
74
+ constructor(
75
+ private db: Db,
76
+ private options: {
77
+ /** Named auth.apiKeyScopes config used to mint shift keys (optional). */
78
+ apiKeyScope?: string | undefined;
79
+ /** Shift-credential lifetime in seconds. Default: 12h. */
80
+ credentialTtlSeconds?: number | undefined;
81
+ } = {},
82
+ ) {}
83
+
84
+ async setPin(
85
+ orgId: string,
86
+ input: { operatorId: string; pin: string; canOverride?: boolean | undefined },
87
+ ): Promise<PluginResult<{ operatorId: string; canOverride: boolean }>> {
88
+ if (!/^\d{4,8}$/.test(input.pin)) {
89
+ return Err("PIN must be 4-8 digits");
90
+ }
91
+ const pinHash = await hashPin(input.pin);
92
+ const existing = await this.db
93
+ .select()
94
+ .from(posOperatorPins)
95
+ .where(and(
96
+ eq(posOperatorPins.organizationId, orgId),
97
+ eq(posOperatorPins.operatorId, input.operatorId),
98
+ ));
99
+ if (existing.length > 0) {
100
+ await this.db
101
+ .update(posOperatorPins)
102
+ .set({
103
+ pinHash,
104
+ canOverride: input.canOverride ?? (existing[0] as OperatorPin).canOverride,
105
+ updatedAt: new Date(),
106
+ })
107
+ .where(eq(posOperatorPins.id, (existing[0] as OperatorPin).id));
108
+ } else {
109
+ await this.db.insert(posOperatorPins).values({
110
+ organizationId: orgId,
111
+ operatorId: input.operatorId,
112
+ pinHash,
113
+ canOverride: input.canOverride ?? false,
114
+ });
115
+ }
116
+ return Ok({ operatorId: input.operatorId, canOverride: input.canOverride ?? false });
117
+ }
118
+
119
+ private async verifyOperatorPin(
120
+ orgId: string,
121
+ operatorId: string,
122
+ pin: string,
123
+ ): Promise<OperatorPin | null> {
124
+ const rows = await this.db
125
+ .select()
126
+ .from(posOperatorPins)
127
+ .where(and(
128
+ eq(posOperatorPins.organizationId, orgId),
129
+ eq(posOperatorPins.operatorId, operatorId),
130
+ ));
131
+ const record = rows[0] as OperatorPin | undefined;
132
+ if (!record) return null;
133
+ return (await verifyPinHash(pin, record.pinHash)) ? record : null;
134
+ }
135
+
136
+ /**
137
+ * PIN login: verifies the PIN, resolves the operator's open shift, and
138
+ * mints a short-lived API key bound to it. The terminal's device key
139
+ * authenticates this call (pos:operate); the returned key is personal.
140
+ */
141
+ async pinLogin(
142
+ orgId: string,
143
+ input: { operatorId: string; pin: string; shiftId?: string | undefined },
144
+ auth: PinAuthApi | undefined,
145
+ ): Promise<PluginResult<{
146
+ operatorId: string;
147
+ shiftId: string;
148
+ terminalId: string;
149
+ apiKey: string;
150
+ expiresAt: string;
151
+ }>> {
152
+ const record = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
153
+ if (!record) return Err("Invalid operator or PIN");
154
+
155
+ const conditions = [
156
+ eq(posShifts.organizationId, orgId),
157
+ eq(posShifts.operatorId, input.operatorId),
158
+ eq(posShifts.status, "open"),
159
+ ];
160
+ if (input.shiftId) conditions.push(eq(posShifts.id, input.shiftId));
161
+ const shifts = await this.db.select().from(posShifts).where(and(...conditions));
162
+ const shift = shifts[0] as Shift | undefined;
163
+ if (!shift) return Err("No open shift for this operator — open a shift first");
164
+
165
+ if (!auth) {
166
+ return Err("PIN login requires the Better Auth instance (plugin ctx.auth) — are routes mounted by createServer?");
167
+ }
168
+
169
+ const ttlSeconds = this.options.credentialTtlSeconds ?? 12 * 3600;
170
+ const created = (await auth.api.createApiKey({
171
+ body: {
172
+ // Better Auth caps name length at 32; the full shift id lives on the
173
+ // shift row (metadata.pinLoginApiKeyId links back).
174
+ name: `pos-shift-${shift.id.slice(0, 8)}`,
175
+ userId: input.operatorId,
176
+ expiresIn: ttlSeconds,
177
+ permissions: { pos: ["operate"] },
178
+ // Shift/terminal binding is recorded on the shift row (metadata is
179
+ // disabled by default in Better Auth's apiKey plugin); the key name
180
+ // carries the shift id for traceability.
181
+ // The plugin registers this scope via its apiKeyScopes manifest.
182
+ configId: this.options.apiKeyScope ?? "pos",
183
+ },
184
+ })) as { key?: string; id?: string };
185
+ if (!created?.key) return Err("Failed to mint shift credential");
186
+
187
+ // Record the credential on the shift so admins can trace/revoke it.
188
+ await this.db
189
+ .update(posShifts)
190
+ .set({
191
+ metadata: { ...(shift.metadata ?? {}), pinLoginApiKeyId: created.id ?? null },
192
+ updatedAt: new Date(),
193
+ })
194
+ .where(eq(posShifts.id, shift.id));
195
+
196
+ return Ok({
197
+ operatorId: input.operatorId,
198
+ shiftId: shift.id,
199
+ terminalId: shift.terminalId,
200
+ apiKey: created.key,
201
+ expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
202
+ });
203
+ }
204
+
205
+ /**
206
+ * Manager override: verifies the manager's PIN (record must carry
207
+ * canOverride) and returns an approval envelope for the requested action.
208
+ */
209
+ async override(
210
+ orgId: string,
211
+ input: { operatorId: string; pin: string; action: string },
212
+ ): Promise<PluginResult<{
213
+ approved: true;
214
+ operatorId: string;
215
+ action: string;
216
+ approvedAt: string;
217
+ }>> {
218
+ const record = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
219
+ if (!record) return Err("Invalid operator or PIN");
220
+ if (!record.canOverride) return Err("This operator cannot approve overrides");
221
+ return Ok({
222
+ approved: true,
223
+ operatorId: input.operatorId,
224
+ action: input.action,
225
+ approvedAt: new Date().toISOString(),
226
+ });
227
+ }
228
+ }
package/src/types.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  posTransactions,
7
7
  posPayments,
8
8
  posReturnItems,
9
+ posOperatorPins,
9
10
  } from "./schema.js";
10
11
 
11
12
  export type Terminal = typeof posTerminals.$inferSelect;
@@ -26,6 +27,9 @@ export type PaymentInsert = typeof posPayments.$inferInsert;
26
27
  export type ReturnItem = typeof posReturnItems.$inferSelect;
27
28
  export type ReturnItemInsert = typeof posReturnItems.$inferInsert;
28
29
 
30
+ export type OperatorPin = typeof posOperatorPins.$inferSelect;
31
+ export type OperatorPinInsert = typeof posOperatorPins.$inferInsert;
32
+
29
33
  export type TransactionStatus = "open" | "held" | "completed" | "voided";
30
34
  export type TransactionType = "sale" | "return" | "exchange";
31
35
  export type PaymentMethod = "cash" | "card" | "gift_card" | "store_credit" | "other";
@@ -40,10 +44,18 @@ export interface POSPluginOptions {
40
44
  maxHoldHours?: number;
41
45
  /** Require manager override for discounts above this percentage. Default: 20 */
42
46
  discountOverrideThreshold?: number;
47
+ /** PIN auth runtime (issue #51). */
48
+ pinAuth?: {
49
+ /** Named auth.apiKeyScopes config used to mint per-shift keys. */
50
+ apiKeyScope?: string;
51
+ /** Shift-credential lifetime in seconds. Default: 43200 (12h). */
52
+ credentialTtlSeconds?: number;
53
+ };
43
54
  }
44
55
 
45
56
  export const DEFAULT_POS_OPTIONS: Required<POSPluginOptions> = {
46
57
  defaultCurrency: "USD",
47
58
  maxHoldHours: 24,
48
59
  discountOverrideThreshold: 20,
60
+ pinAuth: {},
49
61
  };