@wtree/payload-ecommerce-coupon 3.71.1 → 3.72.1
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/README.md +57 -5
- package/dist/collections/createReferralProgramsCollection.d.ts.map +1 -1
- package/dist/endpoints/applyCoupon.d.ts.map +1 -1
- package/dist/endpoints/validateCoupon.d.ts.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +271 -39
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +270 -40
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utilities/getCartTotalWithDiscounts.d.ts +15 -0
- package/dist/utilities/getCartTotalWithDiscounts.d.ts.map +1 -0
- package/dist/utilities/roundTo2.d.ts +5 -0
- package/dist/utilities/roundTo2.d.ts.map +1 -0
- package/dist/utilities/sanitizePluginConfig.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,10 +14,11 @@ Production-ready coupon and referral system plugin for **Payload CMS** with seam
|
|
|
14
14
|
- **Hybrid Mode** (`enableReferrals: true` + `referralConfig.allowBothSystems: true`) – Both systems active
|
|
15
15
|
|
|
16
16
|
### **Coupon Mode Features**
|
|
17
|
-
- ✅ **Flexible Discounts** – Percentage or fixed amount discounts
|
|
18
|
-
- ✅ **Usage Controls** –
|
|
17
|
+
- ✅ **Flexible Discounts** – Percentage or fixed amount discounts (all amounts rounded to 2 decimals)
|
|
18
|
+
- ✅ **Usage Controls** – Global usage limit; usage is counted when an **order is placed** (not on apply)
|
|
19
|
+
- ✅ **Per-customer limit** – Optional limit per customer (requires `customerEmail` when applying)
|
|
19
20
|
- ✅ **Conditions** – Minimum/maximum order values (top-level fields), product restrictions
|
|
20
|
-
- ✅ **Auto-Application** – Seamless cart integration
|
|
21
|
+
- ✅ **Auto-Application** – Seamless cart integration; cart total is reduced when a code is applied
|
|
21
22
|
|
|
22
23
|
### **Referral Mode Features**
|
|
23
24
|
- ✅ **Commission Rules** – **Required.** At least one rule per program. Each rule has **Referrer Reward** (partner commission) and **Referee Reward** (customer discount) inside it, plus appliesTo (all products / categories / products).
|
|
@@ -32,6 +33,7 @@ Production-ready coupon and referral system plugin for **Payload CMS** with seam
|
|
|
32
33
|
- ✅ **Frontend Hooks** – `useCouponCode()`, `usePartnerStats()`, `validateCouponCode()` for React/Next.js
|
|
33
34
|
- ✅ **Auto-Integration** – Extends carts/orders automatically
|
|
34
35
|
- ✅ **Usage on Order** – Coupon/referral usage and partner earnings are recorded when an order is placed (not when code is applied)
|
|
36
|
+
- ✅ **Cart total helper** – `getCartTotalWithDiscounts(cart)` for host app cart hooks so totals respect discounts
|
|
35
37
|
- ✅ **Type-Safe** – Full TypeScript support
|
|
36
38
|
- ✅ **Access Control** – Role-based permissions with partner role support
|
|
37
39
|
- ✅ **Custom Admin Groups** – Separate "Coupons" and "Referrals" categories
|
|
@@ -100,6 +102,14 @@ export default buildConfig({
|
|
|
100
102
|
isAdmin: ({ req }) => req.user?.role === 'admin',
|
|
101
103
|
isPartner: ({ req }) => req.user?.role === 'partner',
|
|
102
104
|
},
|
|
105
|
+
|
|
106
|
+
// Optional: for per-customer coupon limit (defaults shown)
|
|
107
|
+
// orderIntegration: {
|
|
108
|
+
// ordersSlug: 'orders',
|
|
109
|
+
// orderCustomerEmailField: 'customerEmail',
|
|
110
|
+
// orderPaymentStatusField: 'paymentStatus',
|
|
111
|
+
// orderPaidStatusValue: 'paid',
|
|
112
|
+
// },
|
|
103
113
|
}),
|
|
104
114
|
],
|
|
105
115
|
})
|
|
@@ -183,6 +193,32 @@ if (doc.paymentStatus === 'paid' && (doc.appliedCoupon || doc.appliedReferralCod
|
|
|
183
193
|
- **Coupon:** increments the coupon’s `usageCount`.
|
|
184
194
|
- **Referral:** increments the referral code’s `usageCount` and `successfulReferralsCount`, and adds `order.partnerCommission` to the referral code’s `totalEarnings` and `pendingEarnings` (referrer gets commission; referee discount is already on the order).
|
|
185
195
|
|
|
196
|
+
### 4.5 Coupon usage rules and cart total
|
|
197
|
+
|
|
198
|
+
**Usage rule**
|
|
199
|
+
- A customer can use a coupon until the coupon’s **global usage limit** or **expiry date** (usage is counted when the order is placed, not when the code is applied).
|
|
200
|
+
- **Optional per-customer limit:** If you set **Per customer limit** on a coupon, the customer must provide their email when applying (e.g. `customerEmail` in the apply request). The coupon is rejected once they have that many **paid** orders with that coupon. You can pass `customerEmail` when validating so the UI can show “limit reached” before apply.
|
|
201
|
+
|
|
202
|
+
**Monetary values**
|
|
203
|
+
- All discount, commission, and total values are rounded to **2 decimal places**.
|
|
204
|
+
|
|
205
|
+
**Cart total in your app**
|
|
206
|
+
- The plugin writes the reduced `total` when a code is applied. If your host app recalculates the cart total (e.g. in a `beforeChange` hook when items change), use the formula **total = subtotal − discountAmount − customerDiscount** so the discount is not overwritten. Use the provided helper in your Carts collection:
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
import { getCartTotalWithDiscounts } from '@wtree/payload-ecommerce-coupon'
|
|
210
|
+
|
|
211
|
+
// In your Carts collection beforeChange hook, after setting items/subtotal:
|
|
212
|
+
data.total = getCartTotalWithDiscounts(data)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- **Optional config** for per-customer limit: `orderIntegration` with `ordersSlug`, `orderCustomerEmailField`, `orderPaymentStatusField`, `orderPaidStatusValue` (defaults: `'orders'`, `'customerEmail'`, `'paymentStatus'`, `'paid'`).
|
|
216
|
+
|
|
217
|
+
**Server utilities (for host app)**
|
|
218
|
+
|
|
219
|
+
- `getCartTotalWithDiscounts(cart)` – Returns `roundTo2(subtotal - discountAmount - customerDiscount)`. Use in your Carts `beforeChange` (or wherever you compute total) so the displayed total always reflects coupon/referral discounts.
|
|
220
|
+
- `recordCouponUsageForOrder(payload, order, pluginConfig)` – Call when an order is paid to increment coupon/referral usage and credit partner earnings (see step 4 above).
|
|
221
|
+
|
|
186
222
|
### 5. Frontend Integration
|
|
187
223
|
|
|
188
224
|
#### Apply Coupon/Referral Code
|
|
@@ -198,6 +234,8 @@ function CheckoutComponent() {
|
|
|
198
234
|
const result = await useCouponCode({
|
|
199
235
|
code,
|
|
200
236
|
cartID: cartId,
|
|
237
|
+
// When a coupon has per-customer limit, pass customerEmail so the limit can be enforced
|
|
238
|
+
// customerEmail: customerEmailFromAuthOrForm,
|
|
201
239
|
})
|
|
202
240
|
|
|
203
241
|
if (result.success) {
|
|
@@ -328,21 +366,27 @@ Best when you need both traditional coupons AND partner referrals, but want to e
|
|
|
328
366
|
### **Coupon/Referral Endpoints**
|
|
329
367
|
|
|
330
368
|
#### POST /api/coupons/validate
|
|
331
|
-
Validate a code without applying it.
|
|
369
|
+
Validate a code without applying it. Optionally pass `customerEmail` to check per-customer limit for coupons that have one.
|
|
332
370
|
|
|
333
371
|
```bash
|
|
334
372
|
curl -X POST http://localhost:3000/api/coupons/validate \
|
|
335
373
|
-H "Content-Type: application/json" \
|
|
336
374
|
-d '{"code": "WELCOME10", "cartValue": 5000}'
|
|
375
|
+
|
|
376
|
+
# With per-customer limit check:
|
|
377
|
+
# -d '{"code": "WELCOME10", "cartValue": 5000, "customerEmail": "user@example.com"}'
|
|
337
378
|
```
|
|
338
379
|
|
|
339
380
|
#### POST /api/coupons/apply
|
|
340
|
-
Apply a code to a cart. **Does not** increment usage; usage is recorded when you call the record-order-usage endpoint for a placed order.
|
|
381
|
+
Apply a code to a cart. **Does not** increment usage; usage is recorded when you call the record-order-usage endpoint for a placed order. For coupons with **per-customer limit**, include `customerEmail` so the limit can be enforced.
|
|
341
382
|
|
|
342
383
|
```bash
|
|
343
384
|
curl -X POST http://localhost:3000/api/coupons/apply \
|
|
344
385
|
-H "Content-Type: application/json" \
|
|
345
386
|
-d '{"code": "WELCOME10", "cartID": "cart-123"}'
|
|
387
|
+
|
|
388
|
+
# With per-customer limit (required when coupon has per-customer limit):
|
|
389
|
+
# -d '{"code": "WELCOME10", "cartID": "cart-123", "customerEmail": "user@example.com"}'
|
|
346
390
|
```
|
|
347
391
|
|
|
348
392
|
#### POST /api/coupons/record-order-usage
|
|
@@ -454,6 +498,14 @@ export type CouponPluginOptions = {
|
|
|
454
498
|
showRecentReferrals?: boolean // Show recent referrals (default: true)
|
|
455
499
|
showCommissionBreakdown?: boolean // Show breakdown (default: true)
|
|
456
500
|
}
|
|
501
|
+
|
|
502
|
+
/** Optional: for per-customer coupon limit (query paid orders by customer) */
|
|
503
|
+
orderIntegration?: {
|
|
504
|
+
ordersSlug?: string // Default: 'orders'
|
|
505
|
+
orderCustomerEmailField?: string // Default: 'customerEmail'
|
|
506
|
+
orderPaymentStatusField?: string // Default: 'paymentStatus'
|
|
507
|
+
orderPaidStatusValue?: string // Default: 'paid'
|
|
508
|
+
}
|
|
457
509
|
}
|
|
458
510
|
```
|
|
459
511
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"createReferralProgramsCollection.d.ts","sourceRoot":"","sources":["../../src/collections/createReferralProgramsCollection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE/C,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,UAAU,CAAA;AAE5D,eAAO,MAAM,gCAAgC,GAC3C,cAAc,4BAA4B,KACzC,
|
|
1
|
+
{"version":3,"file":"createReferralProgramsCollection.d.ts","sourceRoot":"","sources":["../../src/collections/createReferralProgramsCollection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE/C,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,UAAU,CAAA;AAE5D,eAAO,MAAM,gCAAgC,GAC3C,cAAc,4BAA4B,KACzC,gBAyWF,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"applyCoupon.d.ts","sourceRoot":"","sources":["../../src/endpoints/applyCoupon.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAEvD,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,UAAU,CAAA;
|
|
1
|
+
{"version":3,"file":"applyCoupon.d.ts","sourceRoot":"","sources":["../../src/endpoints/applyCoupon.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAEvD,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,UAAU,CAAA;AAG5D,KAAK,IAAI,GAAG;IACV,YAAY,EAAE,4BAA4B,CAAA;CAC3C,CAAA;AAED,eAAO,MAAM,kBAAkB,GAC5B,kBAAkB,IAAI,KAAG,cAsFzB,CAAA;AAsZH,eAAO,MAAM,mBAAmB,GAAI,kBAAkB,IAAI,KAAG,QAI3D,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validateCoupon.d.ts","sourceRoot":"","sources":["../../src/endpoints/validateCoupon.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAEvD,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,UAAU,CAAA;
|
|
1
|
+
{"version":3,"file":"validateCoupon.d.ts","sourceRoot":"","sources":["../../src/endpoints/validateCoupon.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAEvD,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,UAAU,CAAA;AAG5D,KAAK,IAAI,GAAG;IACV,YAAY,EAAE,4BAA4B,CAAA;CAC3C,CAAA;AAED,eAAO,MAAM,qBAAqB,GAC/B,kBAAkB,IAAI,KAAG,cAiCzB,CAAA;AAkRH,eAAO,MAAM,sBAAsB,GAAI,kBAAkB,IAAI,KAAG,QAI9D,CAAA"}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,5 +4,8 @@ import { createReferralProgramsCollection } from './collections/createReferralPr
|
|
|
4
4
|
import { payloadEcommerceCouponPlugin } from './plugin';
|
|
5
5
|
export { useCouponCode, usePartnerStats, validateCouponCode } from './client/hooks';
|
|
6
6
|
export { createCouponsCollection, createReferralCodesCollection, createReferralProgramsCollection, payloadEcommerceCouponPlugin as payloadEcommerceCoupon, };
|
|
7
|
-
export
|
|
7
|
+
export { getCartTotalWithDiscounts } from './utilities/getCartTotalWithDiscounts';
|
|
8
|
+
export { recordCouponUsageForOrder } from './utilities/recordCouponUsageForOrder';
|
|
9
|
+
export type { AdminGroupConfig, ApplyCouponHook, ApplyCouponResponse, CouponPluginAccess, CouponPluginCollections, CouponPluginOptions, OrderIntegrationConfig, PartnerDashboardConfig, PartnerDashboardData, PartnerStats, ReferralProgramConfig, } from './types';
|
|
10
|
+
export type { CartLike } from './utilities/getCartTotalWithDiscounts';
|
|
8
11
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,uCAAuC,CAAA;AAC/E,OAAO,EAAE,6BAA6B,EAAE,MAAM,6CAA6C,CAAA;AAC3F,OAAO,EAAE,gCAAgC,EAAE,MAAM,gDAAgD,CAAA;AACjG,OAAO,EAAE,4BAA4B,EAAE,MAAM,UAAU,CAAA;AAEvD,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AACnF,OAAO,EACL,uBAAuB,EACvB,6BAA6B,EAC7B,gCAAgC,EAChC,4BAA4B,IAAI,sBAAsB,GACvD,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,uCAAuC,CAAA;AAC/E,OAAO,EAAE,6BAA6B,EAAE,MAAM,6CAA6C,CAAA;AAC3F,OAAO,EAAE,gCAAgC,EAAE,MAAM,gDAAgD,CAAA;AACjG,OAAO,EAAE,4BAA4B,EAAE,MAAM,UAAU,CAAA;AAEvD,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AACnF,OAAO,EACL,uBAAuB,EACvB,6BAA6B,EAC7B,gCAAgC,EAChC,4BAA4B,IAAI,sBAAsB,GACvD,CAAA;AACD,OAAO,EAAE,yBAAyB,EAAE,MAAM,uCAAuC,CAAA;AACjF,OAAO,EAAE,yBAAyB,EAAE,MAAM,uCAAuC,CAAA;AAEjF,YAAY,EACV,gBAAgB,EAChB,eAAe,EACf,mBAAmB,EACnB,kBAAkB,EAClB,uBAAuB,EACvB,mBAAmB,EACnB,sBAAsB,EACtB,sBAAsB,EACtB,oBAAoB,EACpB,YAAY,EACZ,qBAAqB,GACtB,MAAM,SAAS,CAAA;AAChB,YAAY,EAAE,QAAQ,EAAE,MAAM,uCAAuC,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -283,10 +283,17 @@ const createReferralProgramsCollection = (pluginConfig) => {
|
|
|
283
283
|
if (!data.commissionRules || !Array.isArray(data.commissionRules) || data.commissionRules.length === 0) throw new Error("At least one commission rule is required");
|
|
284
284
|
data.commissionRules.forEach((rule, index) => {
|
|
285
285
|
const r = rule;
|
|
286
|
-
if (
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (
|
|
286
|
+
if (r.basis === "shared") {
|
|
287
|
+
if (!r.totalCommission || r.totalCommission.value == null) throw new Error(`Commission rule ${index + 1}: Total Commission is required for Shared Basis`);
|
|
288
|
+
if (r.referrerSplit == null) throw new Error(`Commission rule ${index + 1}: Referrer Split is required for Shared Basis`);
|
|
289
|
+
if (r.refereeSplit == null) throw new Error(`Commission rule ${index + 1}: Referee Split is required for Shared Basis`);
|
|
290
|
+
if ((r.referrerSplit || 0) + (r.refereeSplit || 0) > 100) throw new Error(`Commission rule ${index + 1}: Referrer + Referee split cannot exceed 100%`);
|
|
291
|
+
} else {
|
|
292
|
+
if (!r.referrerReward || r.referrerReward.value == null) throw new Error(`Commission rule ${index + 1}: Referrer Reward is required`);
|
|
293
|
+
if (!r.refereeReward || r.refereeReward.value == null) throw new Error(`Commission rule ${index + 1}: Referee Reward is required`);
|
|
294
|
+
if (r.referrerReward?.type === "percentage" && r.refereeReward?.type === "percentage") {
|
|
295
|
+
if ((r.referrerReward.value || 0) + (r.refereeReward.value || 0) > 100) throw new Error(`Commission rule ${index + 1}: Referrer + Referee percentage cannot exceed 100%`);
|
|
296
|
+
}
|
|
290
297
|
}
|
|
291
298
|
});
|
|
292
299
|
return data;
|
|
@@ -362,11 +369,81 @@ const createReferralProgramsCollection = (pluginConfig) => {
|
|
|
362
369
|
description: "Products this rule applies to"
|
|
363
370
|
}
|
|
364
371
|
},
|
|
372
|
+
{
|
|
373
|
+
name: "basis",
|
|
374
|
+
type: "select",
|
|
375
|
+
required: true,
|
|
376
|
+
defaultValue: "direct",
|
|
377
|
+
options: [{
|
|
378
|
+
label: "Direct Values",
|
|
379
|
+
value: "direct"
|
|
380
|
+
}, {
|
|
381
|
+
label: "Shared Commission",
|
|
382
|
+
value: "shared"
|
|
383
|
+
}],
|
|
384
|
+
admin: { description: "Direct: Set specific reward/discount for each. Shared: Set a total commission and split it." }
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
name: "totalCommission",
|
|
388
|
+
type: "group",
|
|
389
|
+
admin: {
|
|
390
|
+
condition: (_, siblingData) => siblingData?.basis === "shared",
|
|
391
|
+
description: "Total commission available to be split between partner and customer"
|
|
392
|
+
},
|
|
393
|
+
fields: [
|
|
394
|
+
{
|
|
395
|
+
name: "type",
|
|
396
|
+
type: "select",
|
|
397
|
+
required: true,
|
|
398
|
+
options: [{
|
|
399
|
+
label: "Fixed Amount",
|
|
400
|
+
value: "fixed"
|
|
401
|
+
}, {
|
|
402
|
+
label: "Percentage of Order",
|
|
403
|
+
value: "percentage"
|
|
404
|
+
}],
|
|
405
|
+
defaultValue: "percentage"
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
name: "value",
|
|
409
|
+
type: "number",
|
|
410
|
+
required: true,
|
|
411
|
+
admin: { description: `Total commission value` }
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
name: "maxAmount",
|
|
415
|
+
type: "number",
|
|
416
|
+
admin: { description: `Max commission cap per item in ${defaultCurrency}` }
|
|
417
|
+
}
|
|
418
|
+
]
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
name: "referrerSplit",
|
|
422
|
+
type: "number",
|
|
423
|
+
min: 0,
|
|
424
|
+
max: 100,
|
|
425
|
+
admin: {
|
|
426
|
+
condition: (_, siblingData) => siblingData?.basis === "shared",
|
|
427
|
+
description: "Percentage of total commission given to the Partner (0-100)"
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
name: "refereeSplit",
|
|
432
|
+
type: "number",
|
|
433
|
+
min: 0,
|
|
434
|
+
max: 100,
|
|
435
|
+
admin: {
|
|
436
|
+
condition: (_, siblingData) => siblingData?.basis === "shared",
|
|
437
|
+
description: "Percentage of total commission given as Discount to Customer (0-100)"
|
|
438
|
+
}
|
|
439
|
+
},
|
|
365
440
|
{
|
|
366
441
|
name: "referrerReward",
|
|
367
442
|
type: "group",
|
|
368
|
-
|
|
369
|
-
|
|
443
|
+
admin: {
|
|
444
|
+
condition: (_, siblingData) => siblingData?.basis !== "shared",
|
|
445
|
+
description: "Reward given to the partner who refers others"
|
|
446
|
+
},
|
|
370
447
|
fields: [
|
|
371
448
|
{
|
|
372
449
|
name: "type",
|
|
@@ -398,8 +475,10 @@ const createReferralProgramsCollection = (pluginConfig) => {
|
|
|
398
475
|
{
|
|
399
476
|
name: "refereeReward",
|
|
400
477
|
type: "group",
|
|
401
|
-
|
|
402
|
-
|
|
478
|
+
admin: {
|
|
479
|
+
condition: (_, siblingData) => siblingData?.basis !== "shared",
|
|
480
|
+
description: "Discount given to the customer who was referred"
|
|
481
|
+
},
|
|
403
482
|
fields: [
|
|
404
483
|
{
|
|
405
484
|
name: "type",
|
|
@@ -478,6 +557,15 @@ const createReferralProgramsCollection = (pluginConfig) => {
|
|
|
478
557
|
};
|
|
479
558
|
};
|
|
480
559
|
|
|
560
|
+
//#endregion
|
|
561
|
+
//#region src/utilities/roundTo2.ts
|
|
562
|
+
/**
|
|
563
|
+
* Rounds a number to 2 decimal places (standard for monetary values).
|
|
564
|
+
*/
|
|
565
|
+
function roundTo2(value) {
|
|
566
|
+
return Math.round(value * 100) / 100;
|
|
567
|
+
}
|
|
568
|
+
|
|
481
569
|
//#endregion
|
|
482
570
|
//#region src/endpoints/applyCoupon.ts
|
|
483
571
|
const applyCouponHandler = ({ pluginConfig }) => async (req) => {
|
|
@@ -538,7 +626,7 @@ const applyCouponHandler = ({ pluginConfig }) => async (req) => {
|
|
|
538
626
|
}, { status: 500 });
|
|
539
627
|
}
|
|
540
628
|
};
|
|
541
|
-
async function handleCouponCode({ payload, code, cartID, cart, customerEmail
|
|
629
|
+
async function handleCouponCode({ payload, code, cartID, cart, customerEmail, pluginConfig }) {
|
|
542
630
|
const couponQuery = await payload.find({
|
|
543
631
|
collection: pluginConfig.collections.couponsSlug,
|
|
544
632
|
where: { code: { equals: code } },
|
|
@@ -564,6 +652,26 @@ async function handleCouponCode({ payload, code, cartID, cart, customerEmail: _c
|
|
|
564
652
|
success: false,
|
|
565
653
|
error: "Coupon usage limit exceeded"
|
|
566
654
|
}, { status: 400 });
|
|
655
|
+
if (coupon.perCustomerLimit != null && coupon.perCustomerLimit > 0) {
|
|
656
|
+
const email = typeof customerEmail === "string" ? customerEmail.trim() : "";
|
|
657
|
+
if (!email) return Response.json({
|
|
658
|
+
success: false,
|
|
659
|
+
error: "Customer email is required for this coupon."
|
|
660
|
+
}, { status: 400 });
|
|
661
|
+
const { ordersSlug, orderCustomerEmailField, orderPaymentStatusField, orderPaidStatusValue } = pluginConfig.orderIntegration;
|
|
662
|
+
if ((await payload.find({
|
|
663
|
+
collection: ordersSlug,
|
|
664
|
+
where: { and: [
|
|
665
|
+
{ appliedCoupon: { equals: coupon.id } },
|
|
666
|
+
{ [orderCustomerEmailField]: { equals: email } },
|
|
667
|
+
{ [orderPaymentStatusField]: { equals: orderPaidStatusValue } }
|
|
668
|
+
] },
|
|
669
|
+
limit: 0
|
|
670
|
+
})).totalDocs >= coupon.perCustomerLimit) return Response.json({
|
|
671
|
+
success: false,
|
|
672
|
+
error: "You have reached the maximum uses for this coupon."
|
|
673
|
+
}, { status: 400 });
|
|
674
|
+
}
|
|
567
675
|
if (cart.appliedCoupon === coupon.id) return Response.json({
|
|
568
676
|
success: false,
|
|
569
677
|
error: "Coupon already applied to this cart"
|
|
@@ -579,18 +687,21 @@ async function handleCouponCode({ payload, code, cartID, cart, customerEmail: _c
|
|
|
579
687
|
}, { status: 400 });
|
|
580
688
|
let discount = 0;
|
|
581
689
|
if (coupon.type === "percentage") {
|
|
582
|
-
discount =
|
|
583
|
-
if (coupon.maxDiscountAmount && discount > coupon.maxDiscountAmount) discount = coupon.maxDiscountAmount;
|
|
690
|
+
discount = roundTo2(cartTotal * coupon.value / 100);
|
|
691
|
+
if (coupon.maxDiscountAmount != null && discount > coupon.maxDiscountAmount) discount = roundTo2(coupon.maxDiscountAmount);
|
|
584
692
|
} else if (coupon.type === "fixed") {
|
|
585
|
-
discount = coupon.value;
|
|
586
|
-
if (discount > cartTotal) discount = cartTotal;
|
|
693
|
+
discount = roundTo2(coupon.value);
|
|
694
|
+
if (discount > cartTotal) discount = roundTo2(cartTotal);
|
|
587
695
|
}
|
|
696
|
+
const discountAmount = roundTo2(discount);
|
|
697
|
+
const total = roundTo2(Math.max(0, cartTotal - discountAmount));
|
|
588
698
|
await payload.update({
|
|
589
699
|
collection: "carts",
|
|
590
700
|
id: cartID,
|
|
591
701
|
data: {
|
|
592
702
|
appliedCoupon: coupon.id,
|
|
593
|
-
discountAmount
|
|
703
|
+
discountAmount,
|
|
704
|
+
total
|
|
594
705
|
}
|
|
595
706
|
});
|
|
596
707
|
return Response.json({
|
|
@@ -601,7 +712,7 @@ async function handleCouponCode({ payload, code, cartID, cart, customerEmail: _c
|
|
|
601
712
|
type: coupon.type,
|
|
602
713
|
value: coupon.value
|
|
603
714
|
},
|
|
604
|
-
discount,
|
|
715
|
+
discount: discountAmount,
|
|
605
716
|
currency: pluginConfig.defaultCurrency
|
|
606
717
|
});
|
|
607
718
|
}
|
|
@@ -662,21 +773,25 @@ async function handleReferralCode({ payload, code, cartID, cart, customerEmail:
|
|
|
662
773
|
pluginConfig,
|
|
663
774
|
payload
|
|
664
775
|
});
|
|
776
|
+
const roundedPartnerCommission = roundTo2(partnerCommission);
|
|
777
|
+
const roundedCustomerDiscount = roundTo2(customerDiscount);
|
|
778
|
+
const total = roundTo2(Math.max(0, cartTotal - roundedCustomerDiscount));
|
|
665
779
|
await payload.update({
|
|
666
780
|
collection: "carts",
|
|
667
781
|
id: cartID,
|
|
668
782
|
data: {
|
|
669
783
|
appliedReferralCode: referralCode.id,
|
|
670
|
-
partnerCommission:
|
|
671
|
-
customerDiscount:
|
|
784
|
+
partnerCommission: roundedPartnerCommission,
|
|
785
|
+
customerDiscount: roundedCustomerDiscount,
|
|
786
|
+
total
|
|
672
787
|
}
|
|
673
788
|
});
|
|
674
789
|
return Response.json({
|
|
675
790
|
success: true,
|
|
676
791
|
message: "Referral code applied successfully",
|
|
677
792
|
referralCode: { code: referralCode.code },
|
|
678
|
-
partnerCommission:
|
|
679
|
-
customerDiscount:
|
|
793
|
+
partnerCommission: roundedPartnerCommission,
|
|
794
|
+
customerDiscount: roundedCustomerDiscount,
|
|
680
795
|
currency: pluginConfig.defaultCurrency
|
|
681
796
|
});
|
|
682
797
|
}
|
|
@@ -692,19 +807,30 @@ function calculateCommissionAndDiscount({ cart, program, pluginConfig: _pluginCo
|
|
|
692
807
|
let totalCustomerDiscount = 0;
|
|
693
808
|
for (const item of cartItems) {
|
|
694
809
|
const rule = findApplicableCommissionRule(rules, item);
|
|
695
|
-
if (!rule
|
|
810
|
+
if (!rule) continue;
|
|
696
811
|
const itemPrice = item.price ?? item.unitPrice ?? 0;
|
|
697
812
|
const quantity = item.quantity ?? 1;
|
|
698
813
|
const itemTotal = itemPrice * quantity;
|
|
699
814
|
let itemPartner = 0;
|
|
700
|
-
if (rule.referrerReward.type === "percentage") itemPartner = itemTotal * rule.referrerReward.value / 100;
|
|
701
|
-
else itemPartner = rule.referrerReward.value * quantity;
|
|
702
|
-
if (rule.referrerReward.maxReward != null && itemPartner > rule.referrerReward.maxReward) itemPartner = rule.referrerReward.maxReward;
|
|
703
|
-
totalPartnerCommission += itemPartner;
|
|
704
815
|
let itemCustomer = 0;
|
|
705
|
-
if (rule.
|
|
706
|
-
|
|
707
|
-
|
|
816
|
+
if (rule.basis === "shared") {
|
|
817
|
+
if (!rule.totalCommission || rule.referrerSplit == null || rule.refereeSplit == null) continue;
|
|
818
|
+
let totalPot = 0;
|
|
819
|
+
if (rule.totalCommission.type === "percentage") totalPot = itemTotal * rule.totalCommission.value / 100;
|
|
820
|
+
else totalPot = rule.totalCommission.value * quantity;
|
|
821
|
+
if (rule.totalCommission.maxAmount != null && totalPot > rule.totalCommission.maxAmount) totalPot = rule.totalCommission.maxAmount;
|
|
822
|
+
itemPartner = totalPot * rule.referrerSplit / 100;
|
|
823
|
+
itemCustomer = totalPot * rule.refereeSplit / 100;
|
|
824
|
+
} else {
|
|
825
|
+
if (!rule.referrerReward || !rule.refereeReward) continue;
|
|
826
|
+
if (rule.referrerReward.type === "percentage") itemPartner = itemTotal * rule.referrerReward.value / 100;
|
|
827
|
+
else itemPartner = rule.referrerReward.value * quantity;
|
|
828
|
+
if (rule.referrerReward.maxReward != null && itemPartner > rule.referrerReward.maxReward) itemPartner = rule.referrerReward.maxReward;
|
|
829
|
+
if (rule.refereeReward.type === "percentage") itemCustomer = itemTotal * rule.refereeReward.value / 100;
|
|
830
|
+
else itemCustomer = rule.refereeReward.value * quantity;
|
|
831
|
+
if (rule.refereeReward.maxReward != null && itemCustomer > rule.refereeReward.maxReward) itemCustomer = rule.refereeReward.maxReward;
|
|
832
|
+
}
|
|
833
|
+
totalPartnerCommission += itemPartner;
|
|
708
834
|
totalCustomerDiscount += itemCustomer;
|
|
709
835
|
}
|
|
710
836
|
if (totalCustomerDiscount > cartTotal) totalCustomerDiscount = cartTotal;
|
|
@@ -858,7 +984,7 @@ const partnerStatsEndpoint = ({ pluginConfig }) => ({
|
|
|
858
984
|
//#region src/endpoints/validateCoupon.ts
|
|
859
985
|
const validateCouponHandler = ({ pluginConfig }) => async (req) => {
|
|
860
986
|
const { payload } = req;
|
|
861
|
-
const { code, cartValue, cartID } = req.data || {};
|
|
987
|
+
const { code, cartValue, cartID, customerEmail } = req.data || {};
|
|
862
988
|
if (!code) return Response.json({
|
|
863
989
|
success: false,
|
|
864
990
|
error: "Code is required"
|
|
@@ -874,6 +1000,7 @@ const validateCouponHandler = ({ pluginConfig }) => async (req) => {
|
|
|
874
1000
|
payload,
|
|
875
1001
|
code,
|
|
876
1002
|
cartValue,
|
|
1003
|
+
customerEmail,
|
|
877
1004
|
pluginConfig
|
|
878
1005
|
});
|
|
879
1006
|
} catch (error) {
|
|
@@ -884,7 +1011,7 @@ const validateCouponHandler = ({ pluginConfig }) => async (req) => {
|
|
|
884
1011
|
}, { status: 500 });
|
|
885
1012
|
}
|
|
886
1013
|
};
|
|
887
|
-
async function validateCouponCode$1({ payload, code, cartValue, pluginConfig }) {
|
|
1014
|
+
async function validateCouponCode$1({ payload, code, cartValue, customerEmail, pluginConfig }) {
|
|
888
1015
|
const coupon = await payload.find({
|
|
889
1016
|
collection: pluginConfig.collections.couponsSlug,
|
|
890
1017
|
where: { code: { equals: code } },
|
|
@@ -910,23 +1037,43 @@ async function validateCouponCode$1({ payload, code, cartValue, pluginConfig })
|
|
|
910
1037
|
success: false,
|
|
911
1038
|
error: "Coupon usage limit exceeded"
|
|
912
1039
|
}, { status: 400 });
|
|
913
|
-
if (
|
|
914
|
-
const
|
|
1040
|
+
if (couponData.perCustomerLimit != null && couponData.perCustomerLimit > 0 && typeof customerEmail === "string" && customerEmail.trim().length > 0) {
|
|
1041
|
+
const email = customerEmail.trim();
|
|
1042
|
+
const { ordersSlug, orderCustomerEmailField, orderPaymentStatusField, orderPaidStatusValue } = pluginConfig.orderIntegration;
|
|
1043
|
+
if ((await payload.find({
|
|
1044
|
+
collection: ordersSlug,
|
|
1045
|
+
where: { and: [
|
|
1046
|
+
{ appliedCoupon: { equals: couponData.id } },
|
|
1047
|
+
{ [orderCustomerEmailField]: { equals: email } },
|
|
1048
|
+
{ [orderPaymentStatusField]: { equals: orderPaidStatusValue } }
|
|
1049
|
+
] },
|
|
1050
|
+
limit: 0
|
|
1051
|
+
})).totalDocs >= couponData.perCustomerLimit) return Response.json({
|
|
1052
|
+
success: false,
|
|
1053
|
+
error: "You have reached the maximum uses for this coupon."
|
|
1054
|
+
}, { status: 400 });
|
|
1055
|
+
}
|
|
1056
|
+
if (cartValue !== void 0) {
|
|
1057
|
+
const minOrderValue = couponData.minOrderValue;
|
|
1058
|
+
const maxOrderValue = couponData.maxOrderValue;
|
|
915
1059
|
if (minOrderValue && cartValue < minOrderValue) return Response.json({
|
|
916
1060
|
success: false,
|
|
917
|
-
error: `Minimum order value of ${minOrderValue} required`
|
|
1061
|
+
error: `Minimum order value of ${minOrderValue} ${pluginConfig.defaultCurrency} required`
|
|
918
1062
|
}, { status: 400 });
|
|
919
1063
|
if (maxOrderValue && cartValue > maxOrderValue) return Response.json({
|
|
920
1064
|
success: false,
|
|
921
|
-
error: `Maximum order value of ${maxOrderValue} exceeded`
|
|
1065
|
+
error: `Maximum order value of ${maxOrderValue} ${pluginConfig.defaultCurrency} exceeded`
|
|
922
1066
|
}, { status: 400 });
|
|
923
1067
|
}
|
|
924
1068
|
let discount = 0;
|
|
925
1069
|
if (cartValue !== void 0) {
|
|
926
1070
|
if (couponData.type === "percentage") {
|
|
927
|
-
discount =
|
|
928
|
-
if (couponData.maxDiscountAmount && discount > couponData.maxDiscountAmount) discount = couponData.maxDiscountAmount;
|
|
929
|
-
} else if (couponData.type === "fixed")
|
|
1071
|
+
discount = roundTo2(cartValue * couponData.value / 100);
|
|
1072
|
+
if (couponData.maxDiscountAmount != null && discount > couponData.maxDiscountAmount) discount = roundTo2(couponData.maxDiscountAmount);
|
|
1073
|
+
} else if (couponData.type === "fixed") {
|
|
1074
|
+
discount = roundTo2(couponData.value);
|
|
1075
|
+
if (discount > cartValue) discount = roundTo2(cartValue);
|
|
1076
|
+
}
|
|
930
1077
|
}
|
|
931
1078
|
return Response.json({
|
|
932
1079
|
success: true,
|
|
@@ -1000,14 +1147,16 @@ async function validateReferralCode({ payload, code, cartID, pluginConfig }) {
|
|
|
1000
1147
|
}
|
|
1001
1148
|
if (totalCustomerDiscount > cartTotal) totalCustomerDiscount = cartTotal;
|
|
1002
1149
|
}
|
|
1150
|
+
const roundedPartnerCommission = roundTo2(totalPartnerCommission);
|
|
1151
|
+
const roundedCustomerDiscount = roundTo2(totalCustomerDiscount);
|
|
1003
1152
|
return Response.json({
|
|
1004
1153
|
success: true,
|
|
1005
1154
|
referralCode: {
|
|
1006
1155
|
code: referralData.code,
|
|
1007
|
-
description: `Get ${
|
|
1156
|
+
description: `Get ${roundedCustomerDiscount.toFixed(2)} discount with this referral code`
|
|
1008
1157
|
},
|
|
1009
|
-
partnerCommission:
|
|
1010
|
-
customerDiscount:
|
|
1158
|
+
partnerCommission: roundedPartnerCommission,
|
|
1159
|
+
customerDiscount: roundedCustomerDiscount,
|
|
1011
1160
|
currency: pluginConfig.defaultCurrency
|
|
1012
1161
|
});
|
|
1013
1162
|
}
|
|
@@ -1077,6 +1226,12 @@ const sanitizePluginConfig = ({ pluginConfig }) => {
|
|
|
1077
1226
|
showReferralPerformance: pluginConfig?.partnerDashboard?.showReferralPerformance ?? true,
|
|
1078
1227
|
showRecentReferrals: pluginConfig?.partnerDashboard?.showRecentReferrals ?? true,
|
|
1079
1228
|
showCommissionBreakdown: pluginConfig?.partnerDashboard?.showCommissionBreakdown ?? true
|
|
1229
|
+
},
|
|
1230
|
+
orderIntegration: {
|
|
1231
|
+
ordersSlug: typeof pluginConfig?.orderIntegration?.ordersSlug === "string" && pluginConfig.orderIntegration.ordersSlug.trim().length > 0 ? pluginConfig.orderIntegration.ordersSlug : "orders",
|
|
1232
|
+
orderCustomerEmailField: typeof pluginConfig?.orderIntegration?.orderCustomerEmailField === "string" && pluginConfig.orderIntegration.orderCustomerEmailField.trim().length > 0 ? pluginConfig.orderIntegration.orderCustomerEmailField : "customerEmail",
|
|
1233
|
+
orderPaymentStatusField: typeof pluginConfig?.orderIntegration?.orderPaymentStatusField === "string" && pluginConfig.orderIntegration.orderPaymentStatusField.trim().length > 0 ? pluginConfig.orderIntegration.orderPaymentStatusField : "paymentStatus",
|
|
1234
|
+
orderPaidStatusValue: typeof pluginConfig?.orderIntegration?.orderPaidStatusValue === "string" ? pluginConfig.orderIntegration.orderPaidStatusValue : "paid"
|
|
1080
1235
|
}
|
|
1081
1236
|
};
|
|
1082
1237
|
};
|
|
@@ -1379,11 +1534,88 @@ async function usePartnerStats(apiEndpoint = "/api/referrals/partner-stats") {
|
|
|
1379
1534
|
}
|
|
1380
1535
|
}
|
|
1381
1536
|
|
|
1537
|
+
//#endregion
|
|
1538
|
+
//#region src/utilities/getCartTotalWithDiscounts.ts
|
|
1539
|
+
/**
|
|
1540
|
+
* Computes the cart total after applying plugin discounts.
|
|
1541
|
+
* Use this in your host app's cart beforeChange (or wherever you compute total)
|
|
1542
|
+
* so the amount always reflects coupon/referral discounts and is not overwritten incorrectly.
|
|
1543
|
+
*
|
|
1544
|
+
* Formula: subtotal - discountAmount - customerDiscount (each defaulting to 0).
|
|
1545
|
+
*/
|
|
1546
|
+
function getCartTotalWithDiscounts(cart) {
|
|
1547
|
+
const subtotal = cart.subtotal ?? cart.total ?? 0;
|
|
1548
|
+
const discountAmount = cart.discountAmount ?? 0;
|
|
1549
|
+
const customerDiscount = cart.customerDiscount ?? 0;
|
|
1550
|
+
return roundTo2(Math.max(0, subtotal - discountAmount - customerDiscount));
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
//#endregion
|
|
1554
|
+
//#region src/utilities/recordCouponUsageForOrder.ts
|
|
1555
|
+
/**
|
|
1556
|
+
* Record coupon and referral usage when an order is placed successfully.
|
|
1557
|
+
* Call this once when the order is created/paid (e.g. from Orders collection afterChange hook).
|
|
1558
|
+
*
|
|
1559
|
+
* - Coupon: increments the coupon's usageCount.
|
|
1560
|
+
* - Referral: increments the referral code's usageCount and successfulReferralsCount,
|
|
1561
|
+
* and adds order.partnerCommission to totalEarnings and pendingEarnings (referrer gets commission;
|
|
1562
|
+
* referee discount is already on the order).
|
|
1563
|
+
*/
|
|
1564
|
+
async function recordCouponUsageForOrder(payload, order, pluginConfig) {
|
|
1565
|
+
const result = {
|
|
1566
|
+
recordedCoupon: false,
|
|
1567
|
+
recordedReferral: false
|
|
1568
|
+
};
|
|
1569
|
+
const couponId = order.appliedCoupon == null ? null : typeof order.appliedCoupon === "string" ? order.appliedCoupon : order.appliedCoupon?.id;
|
|
1570
|
+
const referralCodeId = order.appliedReferralCode == null ? null : typeof order.appliedReferralCode === "string" ? order.appliedReferralCode : order.appliedReferralCode?.id;
|
|
1571
|
+
if (couponId) {
|
|
1572
|
+
const coupon = await payload.findByID({
|
|
1573
|
+
collection: pluginConfig.collections.couponsSlug,
|
|
1574
|
+
id: couponId
|
|
1575
|
+
});
|
|
1576
|
+
if (coupon) {
|
|
1577
|
+
await payload.update({
|
|
1578
|
+
collection: pluginConfig.collections.couponsSlug,
|
|
1579
|
+
id: couponId,
|
|
1580
|
+
data: { usageCount: (coupon.usageCount ?? 0) + 1 }
|
|
1581
|
+
});
|
|
1582
|
+
result.recordedCoupon = true;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
if (referralCodeId) {
|
|
1586
|
+
const referralCode = await payload.findByID({
|
|
1587
|
+
collection: pluginConfig.collections.referralCodesSlug,
|
|
1588
|
+
id: referralCodeId
|
|
1589
|
+
});
|
|
1590
|
+
if (referralCode) {
|
|
1591
|
+
const commission = Number(order.partnerCommission) || 0;
|
|
1592
|
+
const currentTotal = Number(referralCode.totalEarnings) || 0;
|
|
1593
|
+
const currentPending = Number(referralCode.pendingEarnings) || 0;
|
|
1594
|
+
const currentUsageCount = Number(referralCode.usageCount) || 0;
|
|
1595
|
+
const currentSuccessful = Number(referralCode.successfulReferralsCount) || 0;
|
|
1596
|
+
await payload.update({
|
|
1597
|
+
collection: pluginConfig.collections.referralCodesSlug,
|
|
1598
|
+
id: referralCodeId,
|
|
1599
|
+
data: {
|
|
1600
|
+
usageCount: currentUsageCount + 1,
|
|
1601
|
+
successfulReferralsCount: currentSuccessful + 1,
|
|
1602
|
+
totalEarnings: currentTotal + commission,
|
|
1603
|
+
pendingEarnings: currentPending + commission
|
|
1604
|
+
}
|
|
1605
|
+
});
|
|
1606
|
+
result.recordedReferral = true;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
return result;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1382
1612
|
//#endregion
|
|
1383
1613
|
exports.createCouponsCollection = createCouponsCollection;
|
|
1384
1614
|
exports.createReferralCodesCollection = createReferralCodesCollection;
|
|
1385
1615
|
exports.createReferralProgramsCollection = createReferralProgramsCollection;
|
|
1616
|
+
exports.getCartTotalWithDiscounts = getCartTotalWithDiscounts;
|
|
1386
1617
|
exports.payloadEcommerceCoupon = payloadEcommerceCouponPlugin;
|
|
1618
|
+
exports.recordCouponUsageForOrder = recordCouponUsageForOrder;
|
|
1387
1619
|
exports.useCouponCode = useCouponCode;
|
|
1388
1620
|
exports.usePartnerStats = usePartnerStats;
|
|
1389
1621
|
exports.validateCouponCode = validateCouponCode;
|