@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/LICENSE +21 -0
- package/README.md +59 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +106 -0
- package/dist/schema.d.ts +444 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +45 -0
- package/dist/service.d.ts +69 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +204 -0
- package/package.json +60 -0
- package/src/index.ts +129 -0
- package/src/schema.ts +57 -0
- package/src/service.ts +267 -0
package/src/service.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { eq, and } from "@porulle/core/drizzle";
|
|
2
|
+
import { Ok, Err } from "@porulle/core";
|
|
3
|
+
import type { PluginDb, PluginResult } from "@porulle/core";
|
|
4
|
+
import { layaways, layawayPayments, type LayawayItem } from "./schema.js";
|
|
5
|
+
|
|
6
|
+
export type Layaway = typeof layaways.$inferSelect;
|
|
7
|
+
export type LayawayPayment = typeof layawayPayments.$inferSelect;
|
|
8
|
+
|
|
9
|
+
export interface LayawayPluginOptions {
|
|
10
|
+
/** Default deposit percentage when neither depositAmount nor depositPercent is given. Default: 20. */
|
|
11
|
+
defaultDepositPercent?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Forfeit policy hook — runs after a layaway is forfeited (deposit
|
|
14
|
+
* retention, customer notification, etc.). Errors are surfaced to the
|
|
15
|
+
* caller.
|
|
16
|
+
*/
|
|
17
|
+
onForfeit?: (layaway: Layaway) => Promise<void> | void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface CoreServices {
|
|
21
|
+
inventory: {
|
|
22
|
+
reserve(input: { entityId: string; variantId?: string; quantity: number; orderId: string; performedBy?: string }, actor?: unknown): Promise<{ ok: boolean; error?: { message?: string } }>;
|
|
23
|
+
release(input: { entityId: string; variantId?: string; quantity: number; orderId: string; performedBy?: string }, actor?: unknown): Promise<{ ok: boolean; error?: { message?: string } }>;
|
|
24
|
+
};
|
|
25
|
+
orders: {
|
|
26
|
+
create(input: Record<string, unknown>, actor: unknown): Promise<{ ok: boolean; value?: { id: string }; error?: { message?: string } }>;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Layaway plans (issue #58): reserve items with a deposit, pay in
|
|
32
|
+
* installments, complete to a core order automatically at full payment.
|
|
33
|
+
*/
|
|
34
|
+
export class LayawayService {
|
|
35
|
+
constructor(
|
|
36
|
+
private db: PluginDb,
|
|
37
|
+
private services: Record<string, unknown>,
|
|
38
|
+
private options: LayawayPluginOptions = {},
|
|
39
|
+
) {}
|
|
40
|
+
|
|
41
|
+
private get core(): CoreServices {
|
|
42
|
+
return this.services as unknown as CoreServices;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async create(
|
|
46
|
+
orgId: string,
|
|
47
|
+
input: {
|
|
48
|
+
currency: string;
|
|
49
|
+
items: LayawayItem[];
|
|
50
|
+
customerId?: string | undefined;
|
|
51
|
+
depositAmount?: number | undefined;
|
|
52
|
+
depositPercent?: number | undefined;
|
|
53
|
+
expiresAt?: string | undefined;
|
|
54
|
+
initialPayment?: { amount: number; method: string; reference?: string | undefined } | undefined;
|
|
55
|
+
},
|
|
56
|
+
actor: { userId: string } & Record<string, unknown>,
|
|
57
|
+
): Promise<PluginResult<{ layaway: Layaway; payments: LayawayPayment[] }>> {
|
|
58
|
+
if (input.items.length === 0) return Err("At least one item is required");
|
|
59
|
+
for (const item of input.items) {
|
|
60
|
+
if (!Number.isInteger(item.quantity) || item.quantity < 1) return Err("Item quantity must be a positive integer");
|
|
61
|
+
if (item.unitPrice < 0) return Err("Item unitPrice must be non-negative");
|
|
62
|
+
}
|
|
63
|
+
const total = input.items.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
|
|
64
|
+
const depositPercent = input.depositPercent ?? this.options.defaultDepositPercent ?? 20;
|
|
65
|
+
const depositAmount = input.depositAmount ?? Math.round((total * depositPercent) / 100);
|
|
66
|
+
if (depositAmount > total) return Err("Deposit cannot exceed the plan total");
|
|
67
|
+
|
|
68
|
+
const rows = await this.db
|
|
69
|
+
.insert(layaways)
|
|
70
|
+
.values({
|
|
71
|
+
organizationId: orgId,
|
|
72
|
+
customerId: input.customerId ?? null,
|
|
73
|
+
currency: input.currency,
|
|
74
|
+
items: input.items,
|
|
75
|
+
total,
|
|
76
|
+
depositAmount,
|
|
77
|
+
createdBy: actor.userId,
|
|
78
|
+
...(input.expiresAt ? { expiresAt: new Date(input.expiresAt) } : {}),
|
|
79
|
+
})
|
|
80
|
+
.returning();
|
|
81
|
+
let layaway = rows[0] as Layaway;
|
|
82
|
+
|
|
83
|
+
// Reserve stock while the plan is active (released on completion/forfeit).
|
|
84
|
+
for (const item of input.items) {
|
|
85
|
+
const reserved = await this.core.inventory.reserve(
|
|
86
|
+
{
|
|
87
|
+
entityId: item.entityId,
|
|
88
|
+
...(item.variantId ? { variantId: item.variantId } : {}),
|
|
89
|
+
quantity: item.quantity,
|
|
90
|
+
orderId: layaway.id,
|
|
91
|
+
performedBy: actor.userId,
|
|
92
|
+
},
|
|
93
|
+
actor,
|
|
94
|
+
);
|
|
95
|
+
if (!reserved.ok) {
|
|
96
|
+
// Roll back: release what was reserved so far and drop the plan.
|
|
97
|
+
await this.releaseItems(layaway, actor, item);
|
|
98
|
+
await this.db.delete(layaways).where(eq(layaways.id, layaway.id));
|
|
99
|
+
return Err(reserved.error?.message ?? `Could not reserve ${item.title}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const payments: LayawayPayment[] = [];
|
|
104
|
+
if (input.initialPayment) {
|
|
105
|
+
const paid = await this.addPayment(orgId, layaway.id, input.initialPayment, actor);
|
|
106
|
+
if (!paid.ok) return Err(paid.error);
|
|
107
|
+
layaway = paid.value.layaway;
|
|
108
|
+
payments.push(paid.value.payment);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return Ok({ layaway, payments });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async releaseItems(
|
|
115
|
+
layaway: Layaway,
|
|
116
|
+
actor: { userId: string } & Record<string, unknown>,
|
|
117
|
+
upTo?: LayawayItem,
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
for (const item of layaway.items) {
|
|
120
|
+
if (upTo && item === upTo) break;
|
|
121
|
+
await this.core.inventory.release(
|
|
122
|
+
{
|
|
123
|
+
entityId: item.entityId,
|
|
124
|
+
...(item.variantId ? { variantId: item.variantId } : {}),
|
|
125
|
+
quantity: item.quantity,
|
|
126
|
+
orderId: layaway.id,
|
|
127
|
+
performedBy: actor.userId,
|
|
128
|
+
},
|
|
129
|
+
actor,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async getById(orgId: string, id: string): Promise<PluginResult<Layaway & { payments: LayawayPayment[] }>> {
|
|
135
|
+
const rows = await this.db
|
|
136
|
+
.select()
|
|
137
|
+
.from(layaways)
|
|
138
|
+
.where(and(eq(layaways.id, id), eq(layaways.organizationId, orgId)));
|
|
139
|
+
const layaway = rows[0] as Layaway | undefined;
|
|
140
|
+
if (!layaway) return Err("Layaway not found");
|
|
141
|
+
const payments = (await this.db
|
|
142
|
+
.select()
|
|
143
|
+
.from(layawayPayments)
|
|
144
|
+
.where(eq(layawayPayments.layawayId, id))) as LayawayPayment[];
|
|
145
|
+
return Ok({ ...layaway, payments });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async list(orgId: string, status?: string): Promise<PluginResult<Layaway[]>> {
|
|
149
|
+
const conditions = [eq(layaways.organizationId, orgId)];
|
|
150
|
+
if (status) conditions.push(eq(layaways.status, status as Layaway["status"]));
|
|
151
|
+
const rows = await this.db.select().from(layaways).where(and(...conditions));
|
|
152
|
+
return Ok(rows as Layaway[]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Records an installment. When cumulative payments reach the plan total,
|
|
157
|
+
* the plan completes automatically: a core order is created (cross-linked)
|
|
158
|
+
* and the inventory hold is released to the normal order flow.
|
|
159
|
+
*/
|
|
160
|
+
async addPayment(
|
|
161
|
+
orgId: string,
|
|
162
|
+
layawayId: string,
|
|
163
|
+
input: { amount: number; method: string; reference?: string | undefined },
|
|
164
|
+
actor: { userId: string } & Record<string, unknown>,
|
|
165
|
+
): Promise<PluginResult<{ layaway: Layaway; payment: LayawayPayment; completed: boolean }>> {
|
|
166
|
+
if (input.amount <= 0) return Err("Payment amount must be positive");
|
|
167
|
+
const found = await this.getById(orgId, layawayId);
|
|
168
|
+
if (!found.ok) return found;
|
|
169
|
+
const layaway = found.value;
|
|
170
|
+
if (layaway.status !== "active") return Err(`Layaway is ${layaway.status}`);
|
|
171
|
+
const remaining = layaway.total - layaway.paidTotal;
|
|
172
|
+
if (input.amount > remaining) {
|
|
173
|
+
return Err(`Payment exceeds the remaining balance (${remaining})`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const paymentRows = await this.db
|
|
177
|
+
.insert(layawayPayments)
|
|
178
|
+
.values({
|
|
179
|
+
layawayId,
|
|
180
|
+
amount: input.amount,
|
|
181
|
+
method: input.method,
|
|
182
|
+
reference: input.reference ?? null,
|
|
183
|
+
performedBy: actor.userId,
|
|
184
|
+
})
|
|
185
|
+
.returning();
|
|
186
|
+
const payment = paymentRows[0] as LayawayPayment;
|
|
187
|
+
|
|
188
|
+
const paidTotal = layaway.paidTotal + input.amount;
|
|
189
|
+
let completed = false;
|
|
190
|
+
let orderId: string | null = null;
|
|
191
|
+
|
|
192
|
+
if (paidTotal >= layaway.total) {
|
|
193
|
+
// Full payment → create the core order and release the hold.
|
|
194
|
+
const order = await this.core.orders.create(
|
|
195
|
+
{
|
|
196
|
+
currency: layaway.currency,
|
|
197
|
+
subtotal: layaway.total,
|
|
198
|
+
taxTotal: 0,
|
|
199
|
+
shippingTotal: 0,
|
|
200
|
+
grandTotal: layaway.total,
|
|
201
|
+
...(layaway.customerId ? { customerId: layaway.customerId } : {}),
|
|
202
|
+
metadata: { layawayId: layaway.id, source: "layaway" },
|
|
203
|
+
lineItems: layaway.items.map((item) => ({
|
|
204
|
+
entityId: item.entityId,
|
|
205
|
+
entityType: "product",
|
|
206
|
+
...(item.variantId ? { variantId: item.variantId } : {}),
|
|
207
|
+
...(item.sku ? { sku: item.sku } : {}),
|
|
208
|
+
title: item.title,
|
|
209
|
+
quantity: item.quantity,
|
|
210
|
+
unitPrice: item.unitPrice,
|
|
211
|
+
totalPrice: item.unitPrice * item.quantity,
|
|
212
|
+
})),
|
|
213
|
+
},
|
|
214
|
+
actor,
|
|
215
|
+
);
|
|
216
|
+
if (!order.ok || !order.value) {
|
|
217
|
+
return Err(order.error?.message ?? "Layaway completion failed to create the order");
|
|
218
|
+
}
|
|
219
|
+
orderId = order.value.id;
|
|
220
|
+
await this.releaseItems({ ...layaway, items: layaway.items }, actor);
|
|
221
|
+
completed = true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const updated = await this.db
|
|
225
|
+
.update(layaways)
|
|
226
|
+
.set({
|
|
227
|
+
paidTotal,
|
|
228
|
+
...(completed ? { status: "completed" as const, orderId } : {}),
|
|
229
|
+
updatedAt: new Date(),
|
|
230
|
+
})
|
|
231
|
+
.where(eq(layaways.id, layawayId))
|
|
232
|
+
.returning();
|
|
233
|
+
|
|
234
|
+
return Ok({ layaway: updated[0] as Layaway, payment, completed });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Forfeits an active plan: releases stock and runs the forfeit policy hook. */
|
|
238
|
+
async forfeit(
|
|
239
|
+
orgId: string,
|
|
240
|
+
layawayId: string,
|
|
241
|
+
reason: string | undefined,
|
|
242
|
+
actor: { userId: string } & Record<string, unknown>,
|
|
243
|
+
): Promise<PluginResult<Layaway>> {
|
|
244
|
+
const found = await this.getById(orgId, layawayId);
|
|
245
|
+
if (!found.ok) return found;
|
|
246
|
+
const layaway = found.value;
|
|
247
|
+
if (layaway.status !== "active") return Err(`Layaway is ${layaway.status}`);
|
|
248
|
+
|
|
249
|
+
await this.releaseItems(layaway, actor);
|
|
250
|
+
const rows = await this.db
|
|
251
|
+
.update(layaways)
|
|
252
|
+
.set({
|
|
253
|
+
status: "forfeited",
|
|
254
|
+
forfeitedAt: new Date(),
|
|
255
|
+
forfeitReason: reason ?? null,
|
|
256
|
+
updatedAt: new Date(),
|
|
257
|
+
})
|
|
258
|
+
.where(eq(layaways.id, layawayId))
|
|
259
|
+
.returning();
|
|
260
|
+
const forfeited = rows[0] as Layaway;
|
|
261
|
+
|
|
262
|
+
if (this.options.onForfeit) {
|
|
263
|
+
await this.options.onForfeit(forfeited);
|
|
264
|
+
}
|
|
265
|
+
return Ok(forfeited);
|
|
266
|
+
}
|
|
267
|
+
}
|