@jsdev_ninja/core 0.12.6 → 0.12.7

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,290 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { BundleDiscountStrategy } from "../BundleStrategy";
3
+ import { TDiscount, DiscountContext } from "../../types";
4
+
5
+ describe("BundleDiscountStrategy", () => {
6
+ let strategy: BundleDiscountStrategy;
7
+ let mockContext: DiscountContext;
8
+
9
+ beforeEach(() => {
10
+ strategy = new BundleDiscountStrategy();
11
+
12
+ mockContext = {
13
+ cart: [
14
+ {
15
+ amount: 2,
16
+ product: {
17
+ id: "product1",
18
+ price: 10,
19
+ },
20
+ },
21
+ {
22
+ amount: 1,
23
+ product: {
24
+ id: "product2",
25
+ price: 15,
26
+ },
27
+ },
28
+ ],
29
+ user: undefined,
30
+ appliedDiscounts: [],
31
+ };
32
+ });
33
+
34
+ describe("canApply", () => {
35
+ it("should return true for valid bundle discount", () => {
36
+ const discount: TDiscount = {
37
+ type: "Discount",
38
+ storeId: "store1",
39
+ companyId: "company1",
40
+ id: "bundle1",
41
+ name: [{ lang: "he", value: "Buy 3 for $25" }],
42
+ active: true,
43
+ startDate: Date.now() - 1000,
44
+ endDate: Date.now() + 1000,
45
+ variant: {
46
+ variantType: "bundle",
47
+ productsId: ["product1", "product2"],
48
+ requiredQuantity: 3,
49
+ bundlePrice: 25,
50
+ },
51
+ conditions: {
52
+ stackable: false,
53
+ },
54
+ };
55
+
56
+ expect(strategy.canApply(discount, mockContext)).toBe(true);
57
+ });
58
+
59
+ it("should return false for wrong variant type", () => {
60
+ const discount = {
61
+ ...mockDiscount,
62
+ variant: {
63
+ variantType: "percentage" as any,
64
+ percentage: 20,
65
+ },
66
+ };
67
+
68
+ expect(strategy.canApply(discount, mockContext)).toBe(false);
69
+ });
70
+
71
+ it("should return false when not enough items", () => {
72
+ const discount: TDiscount = {
73
+ type: "Discount",
74
+ storeId: "store1",
75
+ companyId: "company1",
76
+ id: "bundle1",
77
+ name: [{ lang: "he", value: "Buy 5 for $40" }],
78
+ active: true,
79
+ startDate: Date.now() - 1000,
80
+ endDate: Date.now() + 1000,
81
+ variant: {
82
+ variantType: "bundle",
83
+ productsId: ["product1", "product2"],
84
+ requiredQuantity: 5, // Need 5, but cart only has 3
85
+ bundlePrice: 40,
86
+ },
87
+ conditions: {
88
+ stackable: false,
89
+ },
90
+ };
91
+
92
+ expect(strategy.canApply(discount, mockContext)).toBe(false);
93
+ });
94
+
95
+ it("should return false when discount is inactive", () => {
96
+ const discount: TDiscount = {
97
+ type: "Discount",
98
+ storeId: "store1",
99
+ companyId: "company1",
100
+ id: "bundle1",
101
+ name: [{ lang: "he", value: "Buy 3 for $25" }],
102
+ active: false, // Inactive
103
+ startDate: Date.now() - 1000,
104
+ endDate: Date.now() + 1000,
105
+ variant: {
106
+ variantType: "bundle",
107
+ productsId: ["product1", "product2"],
108
+ requiredQuantity: 3,
109
+ bundlePrice: 25,
110
+ },
111
+ conditions: {
112
+ stackable: false,
113
+ },
114
+ };
115
+
116
+ expect(strategy.canApply(discount, mockContext)).toBe(false);
117
+ });
118
+
119
+ it("should return false when discount is expired", () => {
120
+ const discount: TDiscount = {
121
+ type: "Discount",
122
+ storeId: "store1",
123
+ companyId: "company1",
124
+ id: "bundle1",
125
+ name: [{ lang: "he", value: "Buy 3 for $25" }],
126
+ active: true,
127
+ startDate: Date.now() - 2000,
128
+ endDate: Date.now() - 1000, // Expired
129
+ variant: {
130
+ variantType: "bundle",
131
+ productsId: ["product1", "product2"],
132
+ requiredQuantity: 3,
133
+ bundlePrice: 25,
134
+ },
135
+ conditions: {
136
+ stackable: false,
137
+ },
138
+ };
139
+
140
+ expect(strategy.canApply(discount, mockContext)).toBe(false);
141
+ });
142
+ });
143
+
144
+ describe("calculate", () => {
145
+ it("should calculate bundle discount correctly", () => {
146
+ const discount: TDiscount = {
147
+ type: "Discount",
148
+ storeId: "store1",
149
+ companyId: "company1",
150
+ id: "bundle1",
151
+ name: [{ lang: "he", value: "Buy 3 for $25" }],
152
+ active: true,
153
+ startDate: Date.now() - 1000,
154
+ endDate: Date.now() + 1000,
155
+ variant: {
156
+ variantType: "bundle",
157
+ productsId: ["product1", "product2"],
158
+ requiredQuantity: 3,
159
+ bundlePrice: 25,
160
+ },
161
+ conditions: {
162
+ stackable: false,
163
+ },
164
+ };
165
+
166
+ const result = strategy.calculate(discount, mockContext);
167
+
168
+ expect(result.applicable).toBe(true);
169
+ expect(result.discountAmount).toBe(10); // (2×10 + 1×15) - 25 = 35 - 25 = 10
170
+ expect(result.affectedItems).toHaveLength(2);
171
+
172
+ // Check product1 discount
173
+ const product1Item = result.affectedItems.find(item => item.productId === "product1");
174
+ expect(product1Item).toBeDefined();
175
+ expect(product1Item!.discountAmount).toBeCloseTo(5.71, 2); // Proportional to 20/35
176
+
177
+ // Check product2 discount
178
+ const product2Item = result.affectedItems.find(item => item.productId === "product2");
179
+ expect(product2Item).toBeDefined();
180
+ expect(product2Item!.discountAmount).toBeCloseTo(4.29, 2); // Proportional to 15/35
181
+ });
182
+
183
+ it("should handle multiple bundles", () => {
184
+ const contextWithMoreItems: DiscountContext = {
185
+ cart: [
186
+ {
187
+ amount: 6, // 6 items
188
+ product: {
189
+ id: "product1",
190
+ price: 10,
191
+ },
192
+ },
193
+ ],
194
+ user: undefined,
195
+ appliedDiscounts: [],
196
+ };
197
+
198
+ const discount: TDiscount = {
199
+ type: "Discount",
200
+ storeId: "store1",
201
+ companyId: "company1",
202
+ id: "bundle1",
203
+ name: [{ lang: "he", value: "Buy 3 for $25" }],
204
+ active: true,
205
+ startDate: Date.now() - 1000,
206
+ endDate: Date.now() + 1000,
207
+ variant: {
208
+ variantType: "bundle",
209
+ productsId: ["product1"],
210
+ requiredQuantity: 3,
211
+ bundlePrice: 25,
212
+ },
213
+ conditions: {
214
+ stackable: false,
215
+ },
216
+ };
217
+
218
+ const result = strategy.calculate(discount, contextWithMoreItems);
219
+
220
+ expect(result.applicable).toBe(true);
221
+ expect(result.discountAmount).toBe(10); // (6×10) - (2×25) = 60 - 50 = 10
222
+ expect(result.affectedItems).toHaveLength(1);
223
+ expect(result.affectedItems[0].discountAmount).toBe(10);
224
+ });
225
+
226
+ it("should return not applicable for wrong variant type", () => {
227
+ const discount = {
228
+ ...mockDiscount,
229
+ variant: {
230
+ variantType: "percentage" as any,
231
+ percentage: 20,
232
+ },
233
+ };
234
+
235
+ const result = strategy.calculate(discount, mockContext);
236
+
237
+ expect(result.applicable).toBe(false);
238
+ expect(result.discountAmount).toBe(0);
239
+ expect(result.affectedItems).toHaveLength(0);
240
+ });
241
+
242
+ it("should return not applicable when not enough items", () => {
243
+ const discount: TDiscount = {
244
+ type: "Discount",
245
+ storeId: "store1",
246
+ companyId: "company1",
247
+ id: "bundle1",
248
+ name: [{ lang: "he", value: "Buy 5 for $40" }],
249
+ active: true,
250
+ startDate: Date.now() - 1000,
251
+ endDate: Date.now() + 1000,
252
+ variant: {
253
+ variantType: "bundle",
254
+ productsId: ["product1", "product2"],
255
+ requiredQuantity: 5,
256
+ bundlePrice: 40,
257
+ },
258
+ conditions: {
259
+ stackable: false,
260
+ },
261
+ };
262
+
263
+ const result = strategy.calculate(discount, mockContext);
264
+
265
+ expect(result.applicable).toBe(false);
266
+ expect(result.discountAmount).toBe(0);
267
+ expect(result.affectedItems).toHaveLength(0);
268
+ });
269
+ });
270
+ });
271
+
272
+ const mockDiscount: TDiscount = {
273
+ type: "Discount",
274
+ storeId: "store1",
275
+ companyId: "company1",
276
+ id: "bundle1",
277
+ name: [{ lang: "he", value: "Buy 3 for $25" }],
278
+ active: true,
279
+ startDate: Date.now() - 1000,
280
+ endDate: Date.now() + 1000,
281
+ variant: {
282
+ variantType: "bundle",
283
+ productsId: ["product1", "product2"],
284
+ requiredQuantity: 3,
285
+ bundlePrice: 25,
286
+ },
287
+ conditions: {
288
+ stackable: false,
289
+ },
290
+ };
@@ -0,0 +1,6 @@
1
+ import { TDiscount, DiscountResult, DiscountContext } from "./types";
2
+
3
+ export interface DiscountStrategy {
4
+ canApply(discount: TDiscount, context: DiscountContext): boolean;
5
+ calculate(discount: TDiscount, context: DiscountContext): DiscountResult;
6
+ }
@@ -0,0 +1,68 @@
1
+ import { z } from "zod";
2
+
3
+ // Simple discount conditions
4
+ export const DiscountConditionsSchema = z.object({
5
+ minSpend: z.number().positive().optional(),
6
+ stackable: z.boolean().default(false),
7
+ }).optional();
8
+
9
+ // Bundle discount - buy X items for Y price
10
+ export const DiscountVariantSchema = z.discriminatedUnion("variantType", [
11
+ z.object({
12
+ variantType: z.literal("bundle"),
13
+ productsId: z.array(z.string()).min(1), // Which products are included
14
+ requiredQuantity: z.number().positive(), // How many items needed (e.g., 3)
15
+ bundlePrice: z.number().positive(), // Total price for the bundle (e.g., $25)
16
+ }),
17
+ ]);
18
+
19
+ export const DiscountSchema = z.object({
20
+ type: z.literal("Discount"),
21
+ storeId: z.string().min(1),
22
+ companyId: z.string().min(1),
23
+ id: z.string().min(1),
24
+ name: z.array(z.object({ lang: z.enum(["he"]), value: z.string() })),
25
+ active: z.boolean(),
26
+ startDate: z.number(),
27
+ endDate: z.number(),
28
+ variant: DiscountVariantSchema,
29
+ conditions: DiscountConditionsSchema,
30
+ });
31
+
32
+ export type TDiscount = z.infer<typeof DiscountSchema>;
33
+ export type DiscountVariant = z.infer<typeof DiscountVariantSchema>;
34
+ export type DiscountConditions = z.infer<typeof DiscountConditionsSchema>;
35
+
36
+ // Simple result types
37
+ export interface DiscountResult {
38
+ applicable: boolean;
39
+ discountAmount: number;
40
+ affectedItems: Array<{
41
+ productId: string;
42
+ quantity: number;
43
+ originalPrice: number;
44
+ discountedPrice: number;
45
+ discountAmount: number;
46
+ }>;
47
+ }
48
+
49
+ export interface AppliedDiscount {
50
+ discountId: string;
51
+ discountName: string;
52
+ discountAmount: number;
53
+ affectedItems: DiscountResult["affectedItems"];
54
+ }
55
+
56
+ export interface DiscountContext {
57
+ cart: Array<{
58
+ amount: number;
59
+ product: {
60
+ id: string;
61
+ price: number;
62
+ };
63
+ }>;
64
+ user?: Record<string, unknown>;
65
+ appliedDiscounts: AppliedDiscount[];
66
+ }
67
+
68
+
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Utility functions for discount calculations
3
+ */
4
+
5
+ /**
6
+ * Formats a number to 2 decimal places and returns as a number
7
+ * @param value - The number to format
8
+ * @returns The formatted number with 2 decimal places
9
+ */
10
+ export function formatCurrency(value: number): number {
11
+ return Number(value.toFixed(2));
12
+ }
13
+
14
+ /**
15
+ * Formats a number to 2 decimal places and returns as a string
16
+ * @param value - The number to format
17
+ * @returns The formatted string with 2 decimal places
18
+ */
19
+ export function formatCurrencyString(value: number): string {
20
+ return value.toFixed(2);
21
+ }
22
+
23
+ /**
24
+ * Ensures a monetary value is never negative
25
+ * @param value - The value to check
26
+ * @returns The value, or 0 if negative
27
+ */
28
+ export function ensureNonNegative(value: number): number {
29
+ return Math.max(0, formatCurrency(value));
30
+ }
31
+
32
+ /**
33
+ * Calculates the percentage discount
34
+ * @param originalPrice - The original price
35
+ * @param discountedPrice - The discounted price
36
+ * @returns The percentage discount (0-100)
37
+ */
38
+ export function calculatePercentageDiscount(originalPrice: number, discountedPrice: number): number {
39
+ if (originalPrice <= 0) return 0;
40
+ const discount = originalPrice - discountedPrice;
41
+ return formatCurrency((discount / originalPrice) * 100);
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsdev_ninja/core",
3
- "version": "0.12.6",
3
+ "version": "0.12.7",
4
4
  "main": "dist/core.cjs.js",
5
5
  "module": "dist/core.es.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,15 +12,16 @@
12
12
  "build": "vite build && tsc -b",
13
13
  "lint": "eslint .",
14
14
  "preview": "vite preview",
15
- "publish_core": "yarn publish --non-interactive"
15
+ "publish_core": "yarn publish --non-interactive",
16
+ "test": "vitest",
17
+ "test:run": "vitest run"
16
18
  },
17
19
  "dependencies": {
18
20
  "zod": "^3.23.8"
19
21
  },
20
22
  "devDependencies": {
21
- "react": "^18.3.1",
22
- "react-dom": "^18.3.1",
23
23
  "@eslint/js": "^9.9.0",
24
+ "@types/node": "^24.0.15",
24
25
  "@types/react": "^18.3.3",
25
26
  "@types/react-dom": "^18.3.0",
26
27
  "@vitejs/plugin-react-swc": "^3.5.0",
@@ -28,8 +29,12 @@
28
29
  "eslint-plugin-react-hooks": "^5.1.0-rc.0",
29
30
  "eslint-plugin-react-refresh": "^0.4.9",
30
31
  "globals": "^15.9.0",
32
+ "react": "^18.3.1",
33
+ "react-dom": "^18.3.1",
34
+ "ts-node": "^10.9.2",
31
35
  "typescript": "^5.5.3",
32
36
  "typescript-eslint": "^8.0.1",
33
- "vite": "^5.4.1"
37
+ "vite": "^5.4.1",
38
+ "vitest": "^3.2.4"
34
39
  }
35
40
  }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
8
+ exclude: ['node_modules', 'dist'],
9
+ },
10
+ });