@porulle/plugin-pos 0.11.0 → 0.14.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.
@@ -2,6 +2,7 @@ import type { ReturnService } from "../services/return-service.js";
2
2
  import type { TransactionService } from "../services/transaction-service.js";
3
3
  import type { PaymentService } from "../services/payment-service.js";
4
4
  import type { PluginRouteRegistration } from "@porulle/core";
5
+ import type { Db } from "../types.js";
5
6
  export declare function buildReturnRoutes(returnService: ReturnService, transactionService: TransactionService, paymentService: PaymentService, cartService: {
6
7
  create: (input: {
7
8
  currency?: string;
@@ -16,6 +17,7 @@ export declare function buildReturnRoutes(returnService: ReturnService, transact
16
17
  services?: Record<string, unknown>;
17
18
  database?: {
18
19
  db: unknown;
20
+ transaction?: (fn: (tx: Db) => Promise<unknown>) => Promise<unknown>;
19
21
  };
20
22
  }): PluginRouteRegistration[];
21
23
  //# sourceMappingURL=returns.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"returns.d.ts","sourceRoot":"","sources":["../../src/routes/returns.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AACnE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAC7E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AACrE,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AAE7D,wBAAgB,iBAAiB,CAC/B,aAAa,EAAE,aAAa,EAC5B,kBAAkB,EAAE,kBAAkB,EACtC,cAAc,EAAE,cAAc,EAC9B,WAAW,EAAE;IAAE,MAAM,EAAE,CAAC,KAAK,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,EAAE,KAAK,EAAE,OAAO,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE;YAAE,EAAE,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,CAAC,CAAA;CAAE,EAC/J,GAAG,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,QAAQ,CAAC,EAAE;QAAE,EAAE,EAAE,OAAO,CAAA;KAAE,CAAA;CAAE,GACtE,uBAAuB,EAAE,CAgJ3B"}
1
+ {"version":3,"file":"returns.d.ts","sourceRoot":"","sources":["../../src/routes/returns.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AACnE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAC7E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AACrE,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AAC7D,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAQtC,wBAAgB,iBAAiB,CAC/B,aAAa,EAAE,aAAa,EAC5B,kBAAkB,EAAE,kBAAkB,EACtC,cAAc,EAAE,cAAc,EAC9B,WAAW,EAAE;IAAE,MAAM,EAAE,CAAC,KAAK,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,EAAE,KAAK,EAAE,OAAO,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE;YAAE,EAAE,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,CAAC,CAAA;CAAE,EAC/J,GAAG,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,QAAQ,CAAC,EAAE;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,WAAW,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;KAAE,CAAA;CAAE,GAC5I,uBAAuB,EAAE,CAwK3B"}
@@ -1,7 +1,16 @@
1
- import { router } from "@porulle/core";
1
+ import { requireUserId, router } from "@porulle/core";
2
2
  import { z } from "@hono/zod-openapi";
3
+ const paymentInputSchema = z.object({
4
+ method: z.enum(["cash", "card", "gift_card", "store_credit", "other"]),
5
+ amount: z.number().int().positive(),
6
+ reference: z.string().max(200).optional(),
7
+ });
3
8
  export function buildReturnRoutes(returnService, transactionService, paymentService, cartService, ctx) {
4
9
  const r = router("POS Returns", "/pos/returns", ctx);
10
+ const transactionFn = ctx.database?.transaction;
11
+ if (!transactionFn) {
12
+ throw new Error("POS returns require database.transaction from plugin context");
13
+ }
5
14
  r.post("/")
6
15
  .summary("Create return")
7
16
  .permission("pos:manage")
@@ -17,73 +26,75 @@ export function buildReturnRoutes(returnService, transactionService, paymentServ
17
26
  // refundAmount is NOT accepted from the client — it is computed
18
27
  // server-side from the original order (SEC-08).
19
28
  })).min(1),
29
+ payment: paymentInputSchema,
20
30
  }))
21
31
  .handler(async ({ input, actor, orgId }) => {
22
32
  const body = input;
23
- // Create a cart for the return transaction
33
+ // Create a cart for the return transaction (outside the financial tx —
34
+ // same pattern as exchanges).
24
35
  const cartResult = await cartService.create({ currency: "USD", metadata: { posReturn: true } }, actor);
25
36
  if (!cartResult.ok || !cartResult.value) {
26
37
  throw new Error("Failed to create cart for return");
27
38
  }
28
- // Create return transaction
29
- const txnResult = await transactionService.create(orgId, {
30
- shiftId: body.shiftId,
31
- terminalId: body.terminalId,
32
- operatorId: actor.userId,
33
- cartId: cartResult.value.id,
34
- type: "return",
35
- });
36
- if (!txnResult.ok)
37
- throw new Error(txnResult.error);
38
- // SEC-08: derive the refund server-side via orders.refundLines (the same
39
- // primitive the exchange flow uses). It validates the order + line items
40
- // belong to this org, enforces the per-line refundable quantity
41
- // (quantity - refundedQuantity) and updates that ledger — so a fabricated
42
- // or foreign order, an over-refund, or a repeat refund of the same units
43
- // is rejected — and computes the amount from the recorded sale price. The
44
- // client can no longer set the refund amount.
45
39
  const orders = ctx.services?.orders;
46
40
  if (!orders?.refundLines) {
47
41
  throw new Error("Orders service unavailable for return refund");
48
42
  }
49
- const refundResult = await orders.refundLines(body.originalOrderId, {
50
- lines: body.items.map((i) => ({ lineItemId: i.originalLineItemId, quantity: i.quantity })),
51
- reason: "pos_return",
52
- }, actor);
53
- if (!refundResult.ok || !refundResult.value) {
54
- throw new Error(refundResult.error?.message ??
55
- "Return could not be validated against the original order.");
56
- }
57
- const serverAmountByLine = new Map(refundResult.value.refund.lines.map((l) => [l.lineItemId, l.amount]));
58
- const refundTotal = refundResult.value.refund.amount;
59
- // Record return items with the SERVER-computed refund amounts
60
- const itemsResult = await returnService.addReturnItems(txnResult.value.id, body.items.map((item) => ({
61
- ...item,
62
- originalOrderId: body.originalOrderId,
63
- refundAmount: serverAmountByLine.get(item.originalLineItemId) ?? 0,
64
- })));
65
- if (!itemsResult.ok)
66
- throw new Error(itemsResult.error);
67
- await transactionService.updateTotals(txnResult.value.id, {
68
- subtotal: refundTotal,
69
- taxTotal: 0,
70
- total: refundTotal,
71
- discountTotal: 0,
72
- });
73
- return {
74
- transaction: txnResult.value,
75
- returnItems: itemsResult.value,
76
- refundTotal,
77
- };
43
+ const result = await transactionFn(async (tx) => {
44
+ const txCtx = { tx, actor };
45
+ const txnResult = await transactionService.create(orgId, {
46
+ shiftId: body.shiftId,
47
+ terminalId: body.terminalId,
48
+ operatorId: requireUserId(actor),
49
+ cartId: cartResult.value.id,
50
+ type: "return",
51
+ }, tx);
52
+ if (!txnResult.ok)
53
+ throw new Error(txnResult.error);
54
+ const refundResult = await orders.refundLines(body.originalOrderId, {
55
+ lines: body.items.map((i) => ({ lineItemId: i.originalLineItemId, quantity: i.quantity })),
56
+ reason: "pos_return",
57
+ }, actor, txCtx);
58
+ if (!refundResult.ok || !refundResult.value) {
59
+ throw new Error(refundResult.error?.message ??
60
+ "Return could not be validated against the original order.");
61
+ }
62
+ const serverAmountByLine = new Map(refundResult.value.refund.lines.map((l) => [l.lineItemId, l.amount]));
63
+ const refundTotal = refundResult.value.refund.amount;
64
+ if (body.payment.amount > refundTotal) {
65
+ throw new Error(`Payment amount ${body.payment.amount} exceeds refund total ${refundTotal}`);
66
+ }
67
+ const itemsResult = await returnService.addReturnItems(txnResult.value.id, body.items.map((item) => ({
68
+ ...item,
69
+ originalOrderId: body.originalOrderId,
70
+ refundAmount: serverAmountByLine.get(item.originalLineItemId) ?? 0,
71
+ })), tx);
72
+ if (!itemsResult.ok)
73
+ throw new Error(itemsResult.error);
74
+ await transactionService.updateTotals(txnResult.value.id, {
75
+ subtotal: refundTotal,
76
+ taxTotal: 0,
77
+ total: refundTotal,
78
+ discountTotal: 0,
79
+ }, tx);
80
+ const paymentResult = await paymentService.addPayment(orgId, txnResult.value.id, body.payment, tx);
81
+ if (!paymentResult.ok)
82
+ throw new Error(paymentResult.error);
83
+ return {
84
+ transaction: txnResult.value,
85
+ returnItems: itemsResult.value,
86
+ refundTotal,
87
+ payment: paymentResult.value,
88
+ };
89
+ }).catch((error) => error);
90
+ if (result instanceof Error)
91
+ throw result;
92
+ return result;
78
93
  });
79
94
  r.post("/{id}/payments")
80
95
  .summary("Add refund payment")
81
96
  .permission("pos:operate")
82
- .input(z.object({
83
- method: z.enum(["cash", "card", "gift_card", "store_credit", "other"]),
84
- amount: z.number().int().positive(),
85
- reference: z.string().max(200).optional(),
86
- }))
97
+ .input(paymentInputSchema)
87
98
  .handler(async ({ params, input, orgId }) => {
88
99
  const body = input;
89
100
  const result = await paymentService.addPayment(orgId, params.id, body);
@@ -1,4 +1,4 @@
1
- import { router } from "@porulle/core";
1
+ import { requireUserId, router } from "@porulle/core";
2
2
  import { z } from "@hono/zod-openapi";
3
3
  export function buildShiftRoutes(service, ctx) {
4
4
  const r = router("POS Shifts", "/pos/shifts", ctx);
@@ -13,7 +13,7 @@ export function buildShiftRoutes(service, ctx) {
13
13
  const body = input;
14
14
  const result = await service.open(orgId, {
15
15
  ...body,
16
- operatorId: actor.userId,
16
+ operatorId: requireUserId(actor),
17
17
  });
18
18
  if (!result.ok)
19
19
  throw new Error(result.error);
@@ -36,7 +36,7 @@ export function buildShiftRoutes(service, ctx) {
36
36
  .summary("Get current open shift")
37
37
  .permission("pos:operate")
38
38
  .handler(async ({ actor, orgId }) => {
39
- const result = await service.getCurrent(orgId, actor.userId);
39
+ const result = await service.getCurrent(orgId, requireUserId(actor));
40
40
  if (!result.ok)
41
41
  throw new Error(result.error);
42
42
  return result.value;
@@ -72,7 +72,7 @@ export function buildShiftRoutes(service, ctx) {
72
72
  const body = input;
73
73
  const result = await service.addCashEvent(params.id, {
74
74
  ...body,
75
- performedBy: actor.userId,
75
+ performedBy: requireUserId(actor),
76
76
  });
77
77
  if (!result.ok)
78
78
  throw new Error(result.error);
@@ -1,4 +1,4 @@
1
- import { router } from "@porulle/core";
1
+ import { requireUserId, router } from "@porulle/core";
2
2
  import { z } from "@hono/zod-openapi";
3
3
  export function buildTransactionRoutes(service, cartService, ctx) {
4
4
  const r = router("POS Transactions", "/pos/transactions", ctx);
@@ -22,7 +22,7 @@ export function buildTransactionRoutes(service, cartService, ctx) {
22
22
  const result = await service.create(orgId, {
23
23
  shiftId: body.shiftId,
24
24
  terminalId: body.terminalId,
25
- operatorId: actor.userId,
25
+ operatorId: requireUserId(actor),
26
26
  cartId: cartResult.value.id,
27
27
  ...(body.type != null ? { type: body.type } : {}),
28
28
  ...(body.customerId != null ? { customerId: body.customerId } : {}),
@@ -14,11 +14,11 @@ export declare class PaymentService {
14
14
  changeGiven?: number;
15
15
  reference?: string;
16
16
  metadata?: Record<string, unknown>;
17
- }): Promise<PluginResult<Payment>>;
17
+ }, tx?: Db): Promise<PluginResult<Payment>>;
18
18
  /**
19
19
  * Get total payments collected for a transaction.
20
20
  */
21
- getPaymentTotal(transactionId: string): Promise<number>;
21
+ getPaymentTotal(transactionId: string, tx?: Db): Promise<number>;
22
22
  /**
23
23
  * List all payments for a transaction.
24
24
  */
@@ -1 +1 @@
1
- {"version":3,"file":"payment-service.d.ts","sourceRoot":"","sources":["../../src/services/payment-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,OAAO,KAAK,EAAE,EAAE,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE5D,qBAAa,cAAc;IAEvB,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;IAG7E;;;OAGG;IACG,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,KAAK,EAAE;QAC5D,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAC;QACjE,MAAM,EAAE,MAAM,CAAC;QACf,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KACpC,GAAG,OAAO,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC;IAiClC;;OAEG;IACG,eAAe,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAc7D;;OAEG;IACG,YAAY,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,OAAO,EAAE,CAAC,CAAC;IAS3E;;;OAGG;IACG,qBAAqB,CAAC,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;QACtF,WAAW,EAAE,WAAW,CAAC;QACzB,QAAQ,EAAE,OAAO,EAAE,CAAC;QACpB,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC,CAAC;IA8BH;;OAEG;IACG,cAAc,CAAC,aAAa,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;CAOpE"}
1
+ {"version":3,"file":"payment-service.d.ts","sourceRoot":"","sources":["../../src/services/payment-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,OAAO,KAAK,EAAE,EAAE,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE5D,qBAAa,cAAc;IAEvB,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;IAG7E;;;OAGG;IACG,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,KAAK,EAAE;QAC5D,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAC;QACjE,MAAM,EAAE,MAAM,CAAC;QACf,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KACpC,EAAE,EAAE,CAAC,EAAE,EAAE,GAAG,OAAO,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC;IA0C3C;;OAEG;IACG,eAAe,CAAC,aAAa,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC;IAetE;;OAEG;IACG,YAAY,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,OAAO,EAAE,CAAC,CAAC;IAS3E;;;OAGG;IACG,qBAAqB,CAAC,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;QACtF,WAAW,EAAE,WAAW,CAAC;QACzB,QAAQ,EAAE,OAAO,EAAE,CAAC;QACpB,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC,CAAC;IA8BH;;OAEG;IACG,cAAc,CAAC,aAAa,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;CAOpE"}
@@ -12,11 +12,12 @@ export class PaymentService {
12
12
  * Add a payment to a transaction. Does NOT finalize.
13
13
  * Supports split payment (multiple calls per transaction).
14
14
  */
15
- async addPayment(orgId, transactionId, input) {
15
+ async addPayment(orgId, transactionId, input, tx) {
16
16
  if (input.amount <= 0)
17
17
  return Err("Payment amount must be positive");
18
+ const db = tx ?? this.db;
18
19
  // Verify transaction is open
19
- const txns = await this.db
20
+ const txns = await db
20
21
  .select()
21
22
  .from(posTransactions)
22
23
  .where(and(eq(posTransactions.id, transactionId), eq(posTransactions.organizationId, orgId)));
@@ -25,7 +26,13 @@ export class PaymentService {
25
26
  const txn = txns[0];
26
27
  if (txn.status !== "open")
27
28
  return Err("Transaction is not open");
28
- const rows = await this.db
29
+ if (txn.type === "return" && txn.total > 0) {
30
+ const currentPaid = await this.getPaymentTotal(transactionId, db);
31
+ if (currentPaid + input.amount > txn.total) {
32
+ return Err(`Payment would exceed refund total: ${currentPaid + input.amount} > ${txn.total}`);
33
+ }
34
+ }
35
+ const rows = await db
29
36
  .insert(posPayments)
30
37
  .values({
31
38
  transactionId,
@@ -43,8 +50,9 @@ export class PaymentService {
43
50
  /**
44
51
  * Get total payments collected for a transaction.
45
52
  */
46
- async getPaymentTotal(transactionId) {
47
- const rows = await this.db
53
+ async getPaymentTotal(transactionId, tx) {
54
+ const db = tx ?? this.db;
55
+ const rows = await db
48
56
  .select({
49
57
  total: sql `COALESCE(SUM(${posPayments.amount} - ${posPayments.changeGiven}), 0)`.as("total"),
50
58
  })
@@ -14,7 +14,7 @@ export declare class ReturnService {
14
14
  reason: "defective" | "wrong_item" | "changed_mind" | "other";
15
15
  restockingFee?: number;
16
16
  refundAmount: number;
17
- }>): Promise<PluginResult<ReturnItem[]>>;
17
+ }>, tx?: Db): Promise<PluginResult<ReturnItem[]>>;
18
18
  /**
19
19
  * Get all return items for a return transaction.
20
20
  */
@@ -1 +1 @@
1
- {"version":3,"file":"return-service.d.ts","sourceRoot":"","sources":["../../src/services/return-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,OAAO,KAAK,EAAE,EAAE,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAElD,qBAAa,aAAa;IACZ,OAAO,CAAC,EAAE;gBAAF,EAAE,EAAE,EAAE;IAE1B;;;OAGG;IACG,cAAc,CAAC,aAAa,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,CAAC;QACvD,eAAe,EAAE,MAAM,CAAC;QACxB,kBAAkB,EAAE,MAAM,CAAC;QAC3B,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,WAAW,GAAG,YAAY,GAAG,cAAc,GAAG,OAAO,CAAC;QAC9D,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,YAAY,EAAE,MAAM,CAAC;KACtB,CAAC,GAAG,OAAO,CAAC,YAAY,CAAC,UAAU,EAAE,CAAC,CAAC;IAqBxC;;OAEG;IACG,cAAc,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,UAAU,EAAE,CAAC,CAAC;IAShF;;OAEG;IACG,oBAAoB,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAQnE"}
1
+ {"version":3,"file":"return-service.d.ts","sourceRoot":"","sources":["../../src/services/return-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,OAAO,KAAK,EAAE,EAAE,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAElD,qBAAa,aAAa;IACZ,OAAO,CAAC,EAAE;gBAAF,EAAE,EAAE,EAAE;IAE1B;;;OAGG;IACG,cAAc,CAAC,aAAa,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,CAAC;QACvD,eAAe,EAAE,MAAM,CAAC;QACxB,kBAAkB,EAAE,MAAM,CAAC;QAC3B,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,WAAW,GAAG,YAAY,GAAG,cAAc,GAAG,OAAO,CAAC;QAC9D,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,YAAY,EAAE,MAAM,CAAC;KACtB,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,GAAG,OAAO,CAAC,YAAY,CAAC,UAAU,EAAE,CAAC,CAAC;IAsBjD;;OAEG;IACG,cAAc,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,UAAU,EAAE,CAAC,CAAC;IAShF;;OAEG;IACG,oBAAoB,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAQnE"}
@@ -10,9 +10,10 @@ export class ReturnService {
10
10
  * Record return items linking back to an original order's line items.
11
11
  * Called after a return transaction is created.
12
12
  */
13
- async addReturnItems(transactionId, items) {
13
+ async addReturnItems(transactionId, items, tx) {
14
14
  if (items.length === 0)
15
15
  return Err("At least one item is required");
16
+ const db = tx ?? this.db;
16
17
  const values = items.map((item) => ({
17
18
  transactionId,
18
19
  originalOrderId: item.originalOrderId,
@@ -22,7 +23,7 @@ export class ReturnService {
22
23
  restockingFee: item.restockingFee ?? 0,
23
24
  refundAmount: item.refundAmount,
24
25
  }));
25
- const rows = await this.db
26
+ const rows = await db
26
27
  .insert(posReturnItems)
27
28
  .values(values)
28
29
  .returning();
@@ -11,7 +11,7 @@ export declare class TransactionService {
11
11
  cartId: string;
12
12
  type?: "sale" | "return" | "exchange";
13
13
  customerId?: string;
14
- }): Promise<PluginResult<Transaction>>;
14
+ }, tx?: Db): Promise<PluginResult<Transaction>>;
15
15
  getById(orgId: string, id: string): Promise<PluginResult<Transaction>>;
16
16
  hold(orgId: string, id: string, label: string): Promise<PluginResult<Transaction>>;
17
17
  recall(orgId: string, id: string): Promise<PluginResult<Transaction>>;
@@ -23,7 +23,7 @@ export declare class TransactionService {
23
23
  taxTotal: number;
24
24
  total: number;
25
25
  discountTotal: number;
26
- }): Promise<void>;
26
+ }, tx?: Db): Promise<void>;
27
27
  complete(id: string, orderId: string | null): Promise<PluginResult<Transaction>>;
28
28
  private generateReceiptNumber;
29
29
  }
@@ -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;CA2BpC"}
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,EAAE,EAAE,CAAC,EAAE,EAAE,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;IA8BzC,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,EAAE,EAAE,CAAC,EAAE,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAQpB,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;YAgExE,qBAAqB;CA4BpC"}
@@ -8,16 +8,17 @@ export class TransactionService {
8
8
  this.db = db;
9
9
  this.transaction = transaction;
10
10
  }
11
- async create(orgId, input) {
11
+ async create(orgId, input, tx) {
12
+ const db = tx ?? this.db;
12
13
  // Verify shift is open
13
- const shifts = await this.db
14
+ const shifts = await db
14
15
  .select()
15
16
  .from(posShifts)
16
17
  .where(and(eq(posShifts.id, input.shiftId), eq(posShifts.status, "open")));
17
18
  if (shifts.length === 0)
18
19
  return Err("Shift is not open");
19
- const receiptNumber = await this.generateReceiptNumber(orgId, input.terminalId);
20
- const rows = await this.db
20
+ const receiptNumber = await this.generateReceiptNumber(orgId, input.terminalId, db);
21
+ const rows = await db
21
22
  .insert(posTransactions)
22
23
  .values({
23
24
  organizationId: orgId,
@@ -117,8 +118,9 @@ export class TransactionService {
117
118
  .returning();
118
119
  return Ok(rows[0]);
119
120
  }
120
- async updateTotals(id, totals) {
121
- await this.db
121
+ async updateTotals(id, totals, tx) {
122
+ const db = tx ?? this.db;
123
+ await db
122
124
  .update(posTransactions)
123
125
  .set({ ...totals, updatedAt: new Date() })
124
126
  .where(eq(posTransactions.id, id));
@@ -180,19 +182,20 @@ export class TransactionService {
180
182
  }
181
183
  // ─── Receipt Number Generation ─────────────────────────────────────
182
184
  // Sequential per terminal per day: {terminal_code}-{sequence}
183
- async generateReceiptNumber(orgId, terminalId) {
185
+ async generateReceiptNumber(orgId, terminalId, tx) {
186
+ const db = tx ?? this.db;
184
187
  const today = new Date();
185
188
  const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
186
189
  const startOfDayISO = startOfDay.toISOString();
187
190
  // Get terminal code
188
191
  const { posTerminals } = await import("../schema.js");
189
- const terminals = await this.db
192
+ const terminals = await db
190
193
  .select({ code: posTerminals.code })
191
194
  .from(posTerminals)
192
195
  .where(eq(posTerminals.id, terminalId));
193
196
  const terminalCode = terminals[0]?.code ?? "POS";
194
197
  // Count today's transactions for this terminal
195
- const countRows = await this.db
198
+ const countRows = await db
196
199
  .select({ count: sql `COUNT(*)`.as("count") })
197
200
  .from(posTransactions)
198
201
  .where(and(eq(posTransactions.organizationId, orgId), eq(posTransactions.terminalId, terminalId), sql `${posTransactions.createdAt} >= ${startOfDayISO}::timestamptz`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-pos",
3
- "version": "0.11.0",
3
+ "version": "0.14.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,8 +19,8 @@
19
19
  "dependencies": {
20
20
  "@hono/zod-openapi": "^1.2.2",
21
21
  "hono": "^4.12.5",
22
- "@porulle/core": "0.11.0",
23
- "@porulle/db": "0.11.0"
22
+ "@porulle/core": "0.14.0",
23
+ "@porulle/db": "0.14.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^24.5.2",
@@ -1,18 +1,29 @@
1
- import { router } from "@porulle/core";
1
+ import { requireUserId, router } from "@porulle/core";
2
2
  import { z } from "@hono/zod-openapi";
3
3
  import type { ReturnService } from "../services/return-service.js";
4
4
  import type { TransactionService } from "../services/transaction-service.js";
5
5
  import type { PaymentService } from "../services/payment-service.js";
6
6
  import type { PluginRouteRegistration } from "@porulle/core";
7
+ import type { Db } from "../types.js";
8
+
9
+ const paymentInputSchema = z.object({
10
+ method: z.enum(["cash", "card", "gift_card", "store_credit", "other"]),
11
+ amount: z.number().int().positive(),
12
+ reference: z.string().max(200).optional(),
13
+ });
7
14
 
8
15
  export function buildReturnRoutes(
9
16
  returnService: ReturnService,
10
17
  transactionService: TransactionService,
11
18
  paymentService: PaymentService,
12
19
  cartService: { create: (input: { currency?: string; metadata?: Record<string, unknown> }, actor: unknown) => Promise<{ ok: boolean; value?: { id: string } }> },
13
- ctx: { services?: Record<string, unknown>; database?: { db: unknown } },
20
+ ctx: { services?: Record<string, unknown>; database?: { db: unknown; transaction?: (fn: (tx: Db) => Promise<unknown>) => Promise<unknown> } },
14
21
  ): PluginRouteRegistration[] {
15
22
  const r = router("POS Returns", "/pos/returns", ctx);
23
+ const transactionFn = ctx.database?.transaction;
24
+ if (!transactionFn) {
25
+ throw new Error("POS returns require database.transaction from plugin context");
26
+ }
16
27
 
17
28
  r.post("/")
18
29
  .summary("Create return")
@@ -29,6 +40,7 @@ export function buildReturnRoutes(
29
40
  // refundAmount is NOT accepted from the client — it is computed
30
41
  // server-side from the original order (SEC-08).
31
42
  })).min(1),
43
+ payment: paymentInputSchema,
32
44
  }))
33
45
  .handler(async ({ input, actor, orgId }) => {
34
46
  const body = input as {
@@ -41,9 +53,15 @@ export function buildReturnRoutes(
41
53
  reason: "defective" | "wrong_item" | "changed_mind" | "other";
42
54
  restockingFee?: number;
43
55
  }>;
56
+ payment: {
57
+ method: "cash" | "card" | "gift_card" | "store_credit" | "other";
58
+ amount: number;
59
+ reference?: string;
60
+ };
44
61
  };
45
62
 
46
- // Create a cart for the return transaction
63
+ // Create a cart for the return transaction (outside the financial tx —
64
+ // same pattern as exchanges).
47
65
  const cartResult = await cartService.create(
48
66
  { currency: "USD", metadata: { posReturn: true } },
49
67
  actor,
@@ -52,29 +70,13 @@ export function buildReturnRoutes(
52
70
  throw new Error("Failed to create cart for return");
53
71
  }
54
72
 
55
- // Create return transaction
56
- const txnResult = await transactionService.create(orgId, {
57
- shiftId: body.shiftId,
58
- terminalId: body.terminalId,
59
- operatorId: actor!.userId,
60
- cartId: cartResult.value.id,
61
- type: "return",
62
- });
63
- if (!txnResult.ok) throw new Error(txnResult.error);
64
-
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
73
  const orders = ctx.services?.orders as
73
74
  | {
74
75
  refundLines(
75
76
  orderId: string,
76
77
  refundInput: { lines: Array<{ lineItemId: string; quantity: number }>; reason?: string },
77
78
  actor: unknown,
79
+ txCtx?: { tx: Db; actor: unknown },
78
80
  ): Promise<{
79
81
  ok: boolean;
80
82
  value?: { refund: { amount: number; lines: Array<{ lineItemId: string; quantity: number; amount: number }> } };
@@ -86,58 +88,87 @@ export function buildReturnRoutes(
86
88
  throw new Error("Orders service unavailable for return refund");
87
89
  }
88
90
 
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.",
91
+ const result = await transactionFn(async (tx) => {
92
+ const txCtx = { tx, actor };
93
+
94
+ const txnResult = await transactionService.create(orgId, {
95
+ shiftId: body.shiftId,
96
+ terminalId: body.terminalId,
97
+ operatorId: requireUserId(actor),
98
+ cartId: cartResult.value!.id,
99
+ type: "return",
100
+ }, tx);
101
+ if (!txnResult.ok) throw new Error(txnResult.error);
102
+
103
+ const refundResult = await orders.refundLines(
104
+ body.originalOrderId,
105
+ {
106
+ lines: body.items.map((i) => ({ lineItemId: i.originalLineItemId, quantity: i.quantity })),
107
+ reason: "pos_return",
108
+ },
109
+ actor,
110
+ txCtx,
101
111
  );
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
109
- const itemsResult = await returnService.addReturnItems(
110
- txnResult.value.id,
111
- body.items.map((item) => ({
112
- ...item,
113
- originalOrderId: body.originalOrderId,
114
- refundAmount: serverAmountByLine.get(item.originalLineItemId) ?? 0,
115
- })),
116
- );
117
- if (!itemsResult.ok) throw new Error(itemsResult.error);
118
-
119
- await transactionService.updateTotals(txnResult.value.id, {
120
- subtotal: refundTotal,
121
- taxTotal: 0,
122
- total: refundTotal,
123
- discountTotal: 0,
124
- });
125
-
126
- return {
127
- transaction: txnResult.value,
128
- returnItems: itemsResult.value,
129
- refundTotal,
130
- };
112
+ if (!refundResult.ok || !refundResult.value) {
113
+ throw new Error(
114
+ refundResult.error?.message ??
115
+ "Return could not be validated against the original order.",
116
+ );
117
+ }
118
+
119
+ const serverAmountByLine = new Map(
120
+ refundResult.value.refund.lines.map((l) => [l.lineItemId, l.amount]),
121
+ );
122
+ const refundTotal = refundResult.value.refund.amount;
123
+
124
+ if (body.payment.amount > refundTotal) {
125
+ throw new Error(
126
+ `Payment amount ${body.payment.amount} exceeds refund total ${refundTotal}`,
127
+ );
128
+ }
129
+
130
+ const itemsResult = await returnService.addReturnItems(
131
+ txnResult.value.id,
132
+ body.items.map((item) => ({
133
+ ...item,
134
+ originalOrderId: body.originalOrderId,
135
+ refundAmount: serverAmountByLine.get(item.originalLineItemId) ?? 0,
136
+ })),
137
+ tx,
138
+ );
139
+ if (!itemsResult.ok) throw new Error(itemsResult.error);
140
+
141
+ await transactionService.updateTotals(txnResult.value.id, {
142
+ subtotal: refundTotal,
143
+ taxTotal: 0,
144
+ total: refundTotal,
145
+ discountTotal: 0,
146
+ }, tx);
147
+
148
+ const paymentResult = await paymentService.addPayment(
149
+ orgId,
150
+ txnResult.value.id,
151
+ body.payment,
152
+ tx,
153
+ );
154
+ if (!paymentResult.ok) throw new Error(paymentResult.error);
155
+
156
+ return {
157
+ transaction: txnResult.value,
158
+ returnItems: itemsResult.value,
159
+ refundTotal,
160
+ payment: paymentResult.value,
161
+ };
162
+ }).catch((error: unknown) => error as Error);
163
+
164
+ if (result instanceof Error) throw result;
165
+ return result;
131
166
  });
132
167
 
133
168
  r.post("/{id}/payments")
134
169
  .summary("Add refund payment")
135
170
  .permission("pos:operate")
136
- .input(z.object({
137
- method: z.enum(["cash", "card", "gift_card", "store_credit", "other"]),
138
- amount: z.number().int().positive(),
139
- reference: z.string().max(200).optional(),
140
- }))
171
+ .input(paymentInputSchema)
141
172
  .handler(async ({ params, input, orgId }) => {
142
173
  const body = input as { method: "cash" | "card" | "gift_card" | "store_credit" | "other"; amount: number; reference?: string };
143
174
  const result = await paymentService.addPayment(orgId, params.id!, body);
@@ -1,4 +1,4 @@
1
- import { router } from "@porulle/core";
1
+ import { requireUserId, router } from "@porulle/core";
2
2
  import { z } from "@hono/zod-openapi";
3
3
  import type { ShiftService } from "../services/shift-service.js";
4
4
  import type { PluginRouteRegistration } from "@porulle/core";
@@ -20,7 +20,7 @@ export function buildShiftRoutes(
20
20
  const body = input as { terminalId: string; openingFloat: number };
21
21
  const result = await service.open(orgId, {
22
22
  ...body,
23
- operatorId: actor!.userId,
23
+ operatorId: requireUserId(actor),
24
24
  });
25
25
  if (!result.ok) throw new Error(result.error);
26
26
  return result.value;
@@ -43,7 +43,7 @@ export function buildShiftRoutes(
43
43
  .summary("Get current open shift")
44
44
  .permission("pos:operate")
45
45
  .handler(async ({ actor, orgId }) => {
46
- const result = await service.getCurrent(orgId, actor!.userId);
46
+ const result = await service.getCurrent(orgId, requireUserId(actor));
47
47
  if (!result.ok) throw new Error(result.error);
48
48
  return result.value;
49
49
  });
@@ -80,7 +80,7 @@ export function buildShiftRoutes(
80
80
  const body = input as { type: "drop" | "pickup" | "paid_in" | "paid_out"; amount: number; reason?: string };
81
81
  const result = await service.addCashEvent(params.id!, {
82
82
  ...body,
83
- performedBy: actor!.userId,
83
+ performedBy: requireUserId(actor),
84
84
  });
85
85
 
86
86
  if (!result.ok) throw new Error(result.error);
@@ -1,4 +1,4 @@
1
- import { router } from "@porulle/core";
1
+ import { requireUserId, router } from "@porulle/core";
2
2
  import { z } from "@hono/zod-openapi";
3
3
  import type { TransactionService } from "../services/transaction-service.js";
4
4
  import type { PluginRouteRegistration } from "@porulle/core";
@@ -35,7 +35,7 @@ export function buildTransactionRoutes(
35
35
  const result = await service.create(orgId, {
36
36
  shiftId: body.shiftId,
37
37
  terminalId: body.terminalId,
38
- operatorId: actor!.userId,
38
+ operatorId: requireUserId(actor),
39
39
  cartId: cartResult.value.id,
40
40
  ...(body.type != null ? { type: body.type } : {}),
41
41
  ...(body.customerId != null ? { customerId: body.customerId } : {}),
@@ -20,11 +20,13 @@ export class PaymentService {
20
20
  changeGiven?: number;
21
21
  reference?: string;
22
22
  metadata?: Record<string, unknown>;
23
- }): Promise<PluginResult<Payment>> {
23
+ }, tx?: Db): Promise<PluginResult<Payment>> {
24
24
  if (input.amount <= 0) return Err("Payment amount must be positive");
25
25
 
26
+ const db = tx ?? this.db;
27
+
26
28
  // Verify transaction is open
27
- const txns = await this.db
29
+ const txns = await db
28
30
  .select()
29
31
  .from(posTransactions)
30
32
  .where(and(
@@ -36,7 +38,14 @@ export class PaymentService {
36
38
  const txn = txns[0]!;
37
39
  if (txn.status !== "open") return Err("Transaction is not open");
38
40
 
39
- const rows = await this.db
41
+ if (txn.type === "return" && txn.total > 0) {
42
+ const currentPaid = await this.getPaymentTotal(transactionId, db);
43
+ if (currentPaid + input.amount > txn.total) {
44
+ return Err(`Payment would exceed refund total: ${currentPaid + input.amount} > ${txn.total}`);
45
+ }
46
+ }
47
+
48
+ const rows = await db
40
49
  .insert(posPayments)
41
50
  .values({
42
51
  transactionId,
@@ -56,8 +65,9 @@ export class PaymentService {
56
65
  /**
57
66
  * Get total payments collected for a transaction.
58
67
  */
59
- async getPaymentTotal(transactionId: string): Promise<number> {
60
- const rows = await this.db
68
+ async getPaymentTotal(transactionId: string, tx?: Db): Promise<number> {
69
+ const db = tx ?? this.db;
70
+ const rows = await db
61
71
  .select({
62
72
  total: sql<number>`COALESCE(SUM(${posPayments.amount} - ${posPayments.changeGiven}), 0)`.as("total"),
63
73
  })
@@ -18,9 +18,10 @@ export class ReturnService {
18
18
  reason: "defective" | "wrong_item" | "changed_mind" | "other";
19
19
  restockingFee?: number;
20
20
  refundAmount: number;
21
- }>): Promise<PluginResult<ReturnItem[]>> {
21
+ }>, tx?: Db): Promise<PluginResult<ReturnItem[]>> {
22
22
  if (items.length === 0) return Err("At least one item is required");
23
23
 
24
+ const db = tx ?? this.db;
24
25
  const values = items.map((item) => ({
25
26
  transactionId,
26
27
  originalOrderId: item.originalOrderId,
@@ -31,7 +32,7 @@ export class ReturnService {
31
32
  refundAmount: item.refundAmount,
32
33
  }));
33
34
 
34
- const rows = await this.db
35
+ const rows = await db
35
36
  .insert(posReturnItems)
36
37
  .values(values)
37
38
  .returning();
@@ -17,17 +17,19 @@ export class TransactionService {
17
17
  cartId: string;
18
18
  type?: "sale" | "return" | "exchange";
19
19
  customerId?: string;
20
- }): Promise<PluginResult<Transaction>> {
20
+ }, tx?: Db): Promise<PluginResult<Transaction>> {
21
+ const db = tx ?? this.db;
22
+
21
23
  // Verify shift is open
22
- const shifts = await this.db
24
+ const shifts = await db
23
25
  .select()
24
26
  .from(posShifts)
25
27
  .where(and(eq(posShifts.id, input.shiftId), eq(posShifts.status, "open")));
26
28
  if (shifts.length === 0) return Err("Shift is not open");
27
29
 
28
- const receiptNumber = await this.generateReceiptNumber(orgId, input.terminalId);
30
+ const receiptNumber = await this.generateReceiptNumber(orgId, input.terminalId, db);
29
31
 
30
- const rows = await this.db
32
+ const rows = await db
31
33
  .insert(posTransactions)
32
34
  .values({
33
35
  organizationId: orgId,
@@ -148,8 +150,9 @@ export class TransactionService {
148
150
  taxTotal: number;
149
151
  total: number;
150
152
  discountTotal: number;
151
- }): Promise<void> {
152
- await this.db
153
+ }, tx?: Db): Promise<void> {
154
+ const db = tx ?? this.db;
155
+ await db
153
156
  .update(posTransactions)
154
157
  .set({ ...totals, updatedAt: new Date() })
155
158
  .where(eq(posTransactions.id, id));
@@ -219,14 +222,15 @@ export class TransactionService {
219
222
  // ─── Receipt Number Generation ─────────────────────────────────────
220
223
  // Sequential per terminal per day: {terminal_code}-{sequence}
221
224
 
222
- private async generateReceiptNumber(orgId: string, terminalId: string): Promise<string> {
225
+ private async generateReceiptNumber(orgId: string, terminalId: string, tx?: Db): Promise<string> {
226
+ const db = tx ?? this.db;
223
227
  const today = new Date();
224
228
  const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
225
229
  const startOfDayISO = startOfDay.toISOString();
226
230
 
227
231
  // Get terminal code
228
232
  const { posTerminals } = await import("../schema.js");
229
- const terminals = await this.db
233
+ const terminals = await db
230
234
  .select({ code: posTerminals.code })
231
235
  .from(posTerminals)
232
236
  .where(eq(posTerminals.id, terminalId));
@@ -234,7 +238,7 @@ export class TransactionService {
234
238
  const terminalCode = terminals[0]?.code ?? "POS";
235
239
 
236
240
  // Count today's transactions for this terminal
237
- const countRows = await this.db
241
+ const countRows = await db
238
242
  .select({ count: sql<number>`COUNT(*)`.as("count") })
239
243
  .from(posTransactions)
240
244
  .where(and(