@porulle/plugin-layaway 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.
package/dist/schema.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Layaway plugin schema (issue #58).
3
+ *
4
+ * - layaways: a partial-payment plan reserving items until fully paid.
5
+ * `status` is stored but always derived from payments: active →
6
+ * completed (paidTotal >= total, creates the core order + releases the
7
+ * reservation hold) or forfeited/cancelled (releases the hold).
8
+ * - layaway_payments: installment ledger (any tender).
9
+ */
10
+ import { pgTable, uuid, text, integer, timestamp, jsonb, index } from "@porulle/core/drizzle";
11
+ export const layaways = pgTable("layaways", {
12
+ id: uuid("id").defaultRandom().primaryKey(),
13
+ organizationId: text("organization_id").notNull(),
14
+ customerId: uuid("customer_id"),
15
+ status: text("status", { enum: ["active", "completed", "forfeited", "cancelled"] }).notNull().default("active"),
16
+ currency: text("currency").notNull(),
17
+ items: jsonb("items").$type().notNull(),
18
+ total: integer("total").notNull(),
19
+ depositAmount: integer("deposit_amount").notNull().default(0),
20
+ paidTotal: integer("paid_total").notNull().default(0),
21
+ // Core order created at completion.
22
+ orderId: uuid("order_id"),
23
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
24
+ forfeitedAt: timestamp("forfeited_at", { withTimezone: true }),
25
+ forfeitReason: text("forfeit_reason"),
26
+ createdBy: text("created_by").notNull(),
27
+ metadata: jsonb("metadata").$type().default({}),
28
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
29
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
30
+ }, (table) => ({
31
+ orgIdx: index("idx_layaways_org").on(table.organizationId),
32
+ statusIdx: index("idx_layaways_status").on(table.status),
33
+ customerIdx: index("idx_layaways_customer").on(table.customerId),
34
+ }));
35
+ export const layawayPayments = pgTable("layaway_payments", {
36
+ id: uuid("id").defaultRandom().primaryKey(),
37
+ layawayId: uuid("layaway_id").references(() => layaways.id, { onDelete: "cascade" }).notNull(),
38
+ amount: integer("amount").notNull(),
39
+ method: text("method").notNull(),
40
+ reference: text("reference"),
41
+ performedBy: text("performed_by").notNull(),
42
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
43
+ }, (table) => ({
44
+ layawayIdx: index("idx_layaway_payments_layaway").on(table.layawayId),
45
+ }));
@@ -0,0 +1,69 @@
1
+ import type { PluginDb, PluginResult } from "@porulle/core";
2
+ import { layaways, layawayPayments, type LayawayItem } from "./schema.js";
3
+ export type Layaway = typeof layaways.$inferSelect;
4
+ export type LayawayPayment = typeof layawayPayments.$inferSelect;
5
+ export interface LayawayPluginOptions {
6
+ /** Default deposit percentage when neither depositAmount nor depositPercent is given. Default: 20. */
7
+ defaultDepositPercent?: number;
8
+ /**
9
+ * Forfeit policy hook — runs after a layaway is forfeited (deposit
10
+ * retention, customer notification, etc.). Errors are surfaced to the
11
+ * caller.
12
+ */
13
+ onForfeit?: (layaway: Layaway) => Promise<void> | void;
14
+ }
15
+ /**
16
+ * Layaway plans (issue #58): reserve items with a deposit, pay in
17
+ * installments, complete to a core order automatically at full payment.
18
+ */
19
+ export declare class LayawayService {
20
+ private db;
21
+ private services;
22
+ private options;
23
+ constructor(db: PluginDb, services: Record<string, unknown>, options?: LayawayPluginOptions);
24
+ private get core();
25
+ create(orgId: string, input: {
26
+ currency: string;
27
+ items: LayawayItem[];
28
+ customerId?: string | undefined;
29
+ depositAmount?: number | undefined;
30
+ depositPercent?: number | undefined;
31
+ expiresAt?: string | undefined;
32
+ initialPayment?: {
33
+ amount: number;
34
+ method: string;
35
+ reference?: string | undefined;
36
+ } | undefined;
37
+ }, actor: {
38
+ userId: string;
39
+ } & Record<string, unknown>): Promise<PluginResult<{
40
+ layaway: Layaway;
41
+ payments: LayawayPayment[];
42
+ }>>;
43
+ private releaseItems;
44
+ getById(orgId: string, id: string): Promise<PluginResult<Layaway & {
45
+ payments: LayawayPayment[];
46
+ }>>;
47
+ list(orgId: string, status?: string): Promise<PluginResult<Layaway[]>>;
48
+ /**
49
+ * Records an installment. When cumulative payments reach the plan total,
50
+ * the plan completes automatically: a core order is created (cross-linked)
51
+ * and the inventory hold is released to the normal order flow.
52
+ */
53
+ addPayment(orgId: string, layawayId: string, input: {
54
+ amount: number;
55
+ method: string;
56
+ reference?: string | undefined;
57
+ }, actor: {
58
+ userId: string;
59
+ } & Record<string, unknown>): Promise<PluginResult<{
60
+ layaway: Layaway;
61
+ payment: LayawayPayment;
62
+ completed: boolean;
63
+ }>>;
64
+ /** Forfeits an active plan: releases stock and runs the forfeit policy hook. */
65
+ forfeit(orgId: string, layawayId: string, reason: string | undefined, actor: {
66
+ userId: string;
67
+ } & Record<string, unknown>): Promise<PluginResult<Layaway>>;
68
+ }
69
+ //# sourceMappingURL=service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,204 @@
1
+ import { eq, and } from "@porulle/core/drizzle";
2
+ import { Ok, Err } from "@porulle/core";
3
+ import { layaways, layawayPayments } from "./schema.js";
4
+ /**
5
+ * Layaway plans (issue #58): reserve items with a deposit, pay in
6
+ * installments, complete to a core order automatically at full payment.
7
+ */
8
+ export class LayawayService {
9
+ db;
10
+ services;
11
+ options;
12
+ constructor(db, services, options = {}) {
13
+ this.db = db;
14
+ this.services = services;
15
+ this.options = options;
16
+ }
17
+ get core() {
18
+ return this.services;
19
+ }
20
+ async create(orgId, input, actor) {
21
+ if (input.items.length === 0)
22
+ return Err("At least one item is required");
23
+ for (const item of input.items) {
24
+ if (!Number.isInteger(item.quantity) || item.quantity < 1)
25
+ return Err("Item quantity must be a positive integer");
26
+ if (item.unitPrice < 0)
27
+ return Err("Item unitPrice must be non-negative");
28
+ }
29
+ const total = input.items.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
30
+ const depositPercent = input.depositPercent ?? this.options.defaultDepositPercent ?? 20;
31
+ const depositAmount = input.depositAmount ?? Math.round((total * depositPercent) / 100);
32
+ if (depositAmount > total)
33
+ return Err("Deposit cannot exceed the plan total");
34
+ const rows = await this.db
35
+ .insert(layaways)
36
+ .values({
37
+ organizationId: orgId,
38
+ customerId: input.customerId ?? null,
39
+ currency: input.currency,
40
+ items: input.items,
41
+ total,
42
+ depositAmount,
43
+ createdBy: actor.userId,
44
+ ...(input.expiresAt ? { expiresAt: new Date(input.expiresAt) } : {}),
45
+ })
46
+ .returning();
47
+ let layaway = rows[0];
48
+ // Reserve stock while the plan is active (released on completion/forfeit).
49
+ for (const item of input.items) {
50
+ const reserved = await this.core.inventory.reserve({
51
+ entityId: item.entityId,
52
+ ...(item.variantId ? { variantId: item.variantId } : {}),
53
+ quantity: item.quantity,
54
+ orderId: layaway.id,
55
+ performedBy: actor.userId,
56
+ }, actor);
57
+ if (!reserved.ok) {
58
+ // Roll back: release what was reserved so far and drop the plan.
59
+ await this.releaseItems(layaway, actor, item);
60
+ await this.db.delete(layaways).where(eq(layaways.id, layaway.id));
61
+ return Err(reserved.error?.message ?? `Could not reserve ${item.title}`);
62
+ }
63
+ }
64
+ const payments = [];
65
+ if (input.initialPayment) {
66
+ const paid = await this.addPayment(orgId, layaway.id, input.initialPayment, actor);
67
+ if (!paid.ok)
68
+ return Err(paid.error);
69
+ layaway = paid.value.layaway;
70
+ payments.push(paid.value.payment);
71
+ }
72
+ return Ok({ layaway, payments });
73
+ }
74
+ async releaseItems(layaway, actor, upTo) {
75
+ for (const item of layaway.items) {
76
+ if (upTo && item === upTo)
77
+ break;
78
+ await this.core.inventory.release({
79
+ entityId: item.entityId,
80
+ ...(item.variantId ? { variantId: item.variantId } : {}),
81
+ quantity: item.quantity,
82
+ orderId: layaway.id,
83
+ performedBy: actor.userId,
84
+ }, actor);
85
+ }
86
+ }
87
+ async getById(orgId, id) {
88
+ const rows = await this.db
89
+ .select()
90
+ .from(layaways)
91
+ .where(and(eq(layaways.id, id), eq(layaways.organizationId, orgId)));
92
+ const layaway = rows[0];
93
+ if (!layaway)
94
+ return Err("Layaway not found");
95
+ const payments = (await this.db
96
+ .select()
97
+ .from(layawayPayments)
98
+ .where(eq(layawayPayments.layawayId, id)));
99
+ return Ok({ ...layaway, payments });
100
+ }
101
+ async list(orgId, status) {
102
+ const conditions = [eq(layaways.organizationId, orgId)];
103
+ if (status)
104
+ conditions.push(eq(layaways.status, status));
105
+ const rows = await this.db.select().from(layaways).where(and(...conditions));
106
+ return Ok(rows);
107
+ }
108
+ /**
109
+ * Records an installment. When cumulative payments reach the plan total,
110
+ * the plan completes automatically: a core order is created (cross-linked)
111
+ * and the inventory hold is released to the normal order flow.
112
+ */
113
+ async addPayment(orgId, layawayId, input, actor) {
114
+ if (input.amount <= 0)
115
+ return Err("Payment amount must be positive");
116
+ const found = await this.getById(orgId, layawayId);
117
+ if (!found.ok)
118
+ return found;
119
+ const layaway = found.value;
120
+ if (layaway.status !== "active")
121
+ return Err(`Layaway is ${layaway.status}`);
122
+ const remaining = layaway.total - layaway.paidTotal;
123
+ if (input.amount > remaining) {
124
+ return Err(`Payment exceeds the remaining balance (${remaining})`);
125
+ }
126
+ const paymentRows = await this.db
127
+ .insert(layawayPayments)
128
+ .values({
129
+ layawayId,
130
+ amount: input.amount,
131
+ method: input.method,
132
+ reference: input.reference ?? null,
133
+ performedBy: actor.userId,
134
+ })
135
+ .returning();
136
+ const payment = paymentRows[0];
137
+ const paidTotal = layaway.paidTotal + input.amount;
138
+ let completed = false;
139
+ let orderId = null;
140
+ if (paidTotal >= layaway.total) {
141
+ // Full payment → create the core order and release the hold.
142
+ const order = await this.core.orders.create({
143
+ currency: layaway.currency,
144
+ subtotal: layaway.total,
145
+ taxTotal: 0,
146
+ shippingTotal: 0,
147
+ grandTotal: layaway.total,
148
+ ...(layaway.customerId ? { customerId: layaway.customerId } : {}),
149
+ metadata: { layawayId: layaway.id, source: "layaway" },
150
+ lineItems: layaway.items.map((item) => ({
151
+ entityId: item.entityId,
152
+ entityType: "product",
153
+ ...(item.variantId ? { variantId: item.variantId } : {}),
154
+ ...(item.sku ? { sku: item.sku } : {}),
155
+ title: item.title,
156
+ quantity: item.quantity,
157
+ unitPrice: item.unitPrice,
158
+ totalPrice: item.unitPrice * item.quantity,
159
+ })),
160
+ }, actor);
161
+ if (!order.ok || !order.value) {
162
+ return Err(order.error?.message ?? "Layaway completion failed to create the order");
163
+ }
164
+ orderId = order.value.id;
165
+ await this.releaseItems({ ...layaway, items: layaway.items }, actor);
166
+ completed = true;
167
+ }
168
+ const updated = await this.db
169
+ .update(layaways)
170
+ .set({
171
+ paidTotal,
172
+ ...(completed ? { status: "completed", orderId } : {}),
173
+ updatedAt: new Date(),
174
+ })
175
+ .where(eq(layaways.id, layawayId))
176
+ .returning();
177
+ return Ok({ layaway: updated[0], payment, completed });
178
+ }
179
+ /** Forfeits an active plan: releases stock and runs the forfeit policy hook. */
180
+ async forfeit(orgId, layawayId, reason, actor) {
181
+ const found = await this.getById(orgId, layawayId);
182
+ if (!found.ok)
183
+ return found;
184
+ const layaway = found.value;
185
+ if (layaway.status !== "active")
186
+ return Err(`Layaway is ${layaway.status}`);
187
+ await this.releaseItems(layaway, actor);
188
+ const rows = await this.db
189
+ .update(layaways)
190
+ .set({
191
+ status: "forfeited",
192
+ forfeitedAt: new Date(),
193
+ forfeitReason: reason ?? null,
194
+ updatedAt: new Date(),
195
+ })
196
+ .where(eq(layaways.id, layawayId))
197
+ .returning();
198
+ const forfeited = rows[0];
199
+ if (this.options.onForfeit) {
200
+ await this.options.onForfeit(forfeited);
201
+ }
202
+ return Ok(forfeited);
203
+ }
204
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@porulle/plugin-layaway",
3
+ "version": "0.8.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "bun": "./src/index.ts",
9
+ "import": "./dist/index.js",
10
+ "types": "./src/index.ts"
11
+ },
12
+ "./schema": {
13
+ "bun": "./src/schema.ts",
14
+ "import": "./dist/schema.js",
15
+ "require": "./dist/schema.js",
16
+ "types": "./src/schema.ts"
17
+ }
18
+ },
19
+ "dependencies": {
20
+ "@hono/zod-openapi": "^1.2.2",
21
+ "hono": "^4.12.5",
22
+ "@porulle/core": "0.8.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^24.5.2",
26
+ "eslint": "^9.39.1",
27
+ "typescript": "5.9.2",
28
+ "vitest": "^3.2.4",
29
+ "@porulle/eslint-config": "0.1.0",
30
+ "@porulle/typescript-config": "0.1.0"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "dist",
38
+ "README.md"
39
+ ],
40
+ "peerDependencies": {
41
+ "zod": ">=4.0.0"
42
+ },
43
+ "description": "Layaway / partial-payment plans: reserve items with a deposit, pay in installments (any tender), derived status, automatic completion to a core order with stock release, forfeit policy.",
44
+ "homepage": "https://porulle-docs.vercel.app",
45
+ "bugs": {
46
+ "url": "https://github.com/asyncdotengineering/porulle/issues"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/asyncdotengineering/porulle.git",
51
+ "directory": "packages/plugins/plugin-layaway"
52
+ },
53
+ "author": "Porulle contributors",
54
+ "scripts": {
55
+ "build": "rm -rf dist tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json",
56
+ "check-types": "tsc --noEmit",
57
+ "lint": "eslint . --max-warnings 1000",
58
+ "test": "vitest run"
59
+ }
60
+ }
package/src/index.ts ADDED
@@ -0,0 +1,129 @@
1
+ import { defineCommercePlugin, router } from "@porulle/core";
2
+ import { z } from "@hono/zod-openapi";
3
+ import type { PluginRouteRegistration } from "@porulle/core";
4
+ import { layaways, layawayPayments } from "./schema.js";
5
+ import { LayawayService, type LayawayPluginOptions } from "./service.js";
6
+
7
+ export type { LayawayPluginOptions, Layaway, LayawayPayment } from "./service.js";
8
+ export { LayawayService } from "./service.js";
9
+ export type { LayawayItem } from "./schema.js";
10
+
11
+ const ItemSchema = z.object({
12
+ entityId: z.string().uuid(),
13
+ variantId: z.string().uuid().optional(),
14
+ sku: z.string().optional(),
15
+ title: z.string().min(1),
16
+ quantity: z.number().int().positive(),
17
+ unitPrice: z.number().int().min(0),
18
+ });
19
+
20
+ /**
21
+ * Layaway plugin (issue #58): partial-payment plans — reserve items with a
22
+ * deposit, record installments (any tender), derived status, automatic
23
+ * completion to a core order (with the stock hold released) at full payment,
24
+ * and a forfeit policy hook.
25
+ *
26
+ * The actor needs `layaway:operate` for day-to-day flows and core
27
+ * `orders:create` (completion creates the order).
28
+ */
29
+ export function layawayPlugin(options: LayawayPluginOptions = {}) {
30
+ return defineCommercePlugin({
31
+ id: "layaway",
32
+ version: "1.0.0",
33
+
34
+ permissions: [
35
+ { scope: "layaway:operate", description: "Create layaway plans, record installment payments." },
36
+ { scope: "layaway:manage", description: "Forfeit or cancel layaway plans." },
37
+ ],
38
+
39
+ schema: () => ({ layaways, layawayPayments }),
40
+
41
+ routes: (ctx) => {
42
+ const db = ctx.database.db;
43
+ if (!db) return [];
44
+ const service = new LayawayService(db, ctx.services, options);
45
+ const r = router("Layaways", "/layaways", ctx);
46
+
47
+ r.post("/")
48
+ .summary("Create a layaway plan (reserves stock; optional initial deposit payment)")
49
+ .permission("layaway:operate")
50
+ .input(z.object({
51
+ currency: z.string().length(3),
52
+ customerId: z.string().uuid().optional(),
53
+ items: z.array(ItemSchema).min(1),
54
+ depositAmount: z.number().int().min(0).optional(),
55
+ depositPercent: z.number().min(0).max(100).optional(),
56
+ expiresAt: z.string().datetime().optional(),
57
+ initialPayment: z.object({
58
+ amount: z.number().int().positive(),
59
+ method: z.string().min(1),
60
+ reference: z.string().optional(),
61
+ }).optional(),
62
+ }))
63
+ .handler(async ({ input, actor, orgId }) => {
64
+ const result = await service.create(
65
+ orgId,
66
+ input as Parameters<LayawayService["create"]>[1],
67
+ actor as { userId: string } & Record<string, unknown>,
68
+ );
69
+ if (!result.ok) throw new Error(result.error);
70
+ return result.value;
71
+ });
72
+
73
+ r.get("/")
74
+ .summary("List layaway plans")
75
+ .permission("layaway:operate")
76
+ .query(z.object({ status: z.string().optional() }))
77
+ .handler(async ({ query, orgId }) => {
78
+ const result = await service.list(orgId, (query as { status?: string }).status);
79
+ if (!result.ok) throw new Error(result.error);
80
+ return result.value;
81
+ });
82
+
83
+ r.get("/{id}")
84
+ .summary("Get a layaway plan with its payment ledger")
85
+ .permission("layaway:operate")
86
+ .handler(async ({ params, orgId }) => {
87
+ const result = await service.getById(orgId, params.id!);
88
+ if (!result.ok) throw new Error(result.error);
89
+ return result.value;
90
+ });
91
+
92
+ r.post("/{id}/payments")
93
+ .summary("Record an installment (completes the plan at full payment)")
94
+ .permission("layaway:operate")
95
+ .input(z.object({
96
+ amount: z.number().int().positive(),
97
+ method: z.string().min(1),
98
+ reference: z.string().optional(),
99
+ }))
100
+ .handler(async ({ params, input, actor, orgId }) => {
101
+ const result = await service.addPayment(
102
+ orgId,
103
+ params.id!,
104
+ input as { amount: number; method: string; reference?: string },
105
+ actor as { userId: string } & Record<string, unknown>,
106
+ );
107
+ if (!result.ok) throw new Error(result.error);
108
+ return result.value;
109
+ });
110
+
111
+ r.post("/{id}/forfeit")
112
+ .summary("Forfeit an active plan (releases stock, runs the forfeit policy)")
113
+ .permission("layaway:manage")
114
+ .input(z.object({ reason: z.string().max(500).optional() }))
115
+ .handler(async ({ params, input, actor, orgId }) => {
116
+ const result = await service.forfeit(
117
+ orgId,
118
+ params.id!,
119
+ (input as { reason?: string }).reason,
120
+ actor as { userId: string } & Record<string, unknown>,
121
+ );
122
+ if (!result.ok) throw new Error(result.error);
123
+ return result.value;
124
+ });
125
+
126
+ return r.routes() as PluginRouteRegistration[];
127
+ },
128
+ });
129
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Layaway plugin schema (issue #58).
3
+ *
4
+ * - layaways: a partial-payment plan reserving items until fully paid.
5
+ * `status` is stored but always derived from payments: active →
6
+ * completed (paidTotal >= total, creates the core order + releases the
7
+ * reservation hold) or forfeited/cancelled (releases the hold).
8
+ * - layaway_payments: installment ledger (any tender).
9
+ */
10
+
11
+ import { pgTable, uuid, text, integer, timestamp, jsonb, index } from "@porulle/core/drizzle";
12
+
13
+ export interface LayawayItem {
14
+ entityId: string;
15
+ variantId?: string | undefined;
16
+ sku?: string | undefined;
17
+ title: string;
18
+ quantity: number;
19
+ unitPrice: number;
20
+ }
21
+
22
+ export const layaways = pgTable("layaways", {
23
+ id: uuid("id").defaultRandom().primaryKey(),
24
+ organizationId: text("organization_id").notNull(),
25
+ customerId: uuid("customer_id"),
26
+ status: text("status", { enum: ["active", "completed", "forfeited", "cancelled"] }).notNull().default("active"),
27
+ currency: text("currency").notNull(),
28
+ items: jsonb("items").$type<LayawayItem[]>().notNull(),
29
+ total: integer("total").notNull(),
30
+ depositAmount: integer("deposit_amount").notNull().default(0),
31
+ paidTotal: integer("paid_total").notNull().default(0),
32
+ // Core order created at completion.
33
+ orderId: uuid("order_id"),
34
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
35
+ forfeitedAt: timestamp("forfeited_at", { withTimezone: true }),
36
+ forfeitReason: text("forfeit_reason"),
37
+ createdBy: text("created_by").notNull(),
38
+ metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
39
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
40
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
41
+ }, (table) => ({
42
+ orgIdx: index("idx_layaways_org").on(table.organizationId),
43
+ statusIdx: index("idx_layaways_status").on(table.status),
44
+ customerIdx: index("idx_layaways_customer").on(table.customerId),
45
+ }));
46
+
47
+ export const layawayPayments = pgTable("layaway_payments", {
48
+ id: uuid("id").defaultRandom().primaryKey(),
49
+ layawayId: uuid("layaway_id").references(() => layaways.id, { onDelete: "cascade" }).notNull(),
50
+ amount: integer("amount").notNull(),
51
+ method: text("method").notNull(),
52
+ reference: text("reference"),
53
+ performedBy: text("performed_by").notNull(),
54
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
55
+ }, (table) => ({
56
+ layawayIdx: index("idx_layaway_payments_layaway").on(table.layawayId),
57
+ }));