@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 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** – Usage limits; usage is counted when an **order is placed** (not on apply)
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,gBA4PF,CAAA"}
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;AAE5D,KAAK,IAAI,GAAG;IACV,YAAY,EAAE,4BAA4B,CAAA;CAC3C,CAAA;AAED,eAAO,MAAM,kBAAkB,GAC5B,kBAAkB,IAAI,KAAG,cAsFzB,CAAA;AAsVH,eAAO,MAAM,mBAAmB,GAAI,kBAAkB,IAAI,KAAG,QAI3D,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;AAE5D,KAAK,IAAI,GAAG;IACV,YAAY,EAAE,4BAA4B,CAAA;CAC3C,CAAA;AAED,eAAO,MAAM,qBAAqB,GAC/B,kBAAkB,IAAI,KAAG,cA2BzB,CAAA;AA8OH,eAAO,MAAM,sBAAsB,GAAI,kBAAkB,IAAI,KAAG,QAI9D,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 type { AdminGroupConfig, ApplyCouponHook, ApplyCouponResponse, CouponPluginAccess, CouponPluginCollections, CouponPluginOptions, PartnerDashboardConfig, PartnerDashboardData, PartnerStats, ReferralProgramConfig, } from './types';
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
@@ -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;AAED,YAAY,EACV,gBAAgB,EAChB,eAAe,EACf,mBAAmB,EACnB,kBAAkB,EAClB,uBAAuB,EACvB,mBAAmB,EACnB,sBAAsB,EACtB,oBAAoB,EACpB,YAAY,EACZ,qBAAqB,GACtB,MAAM,SAAS,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 (!r.referrerReward || r.referrerReward.value == null) throw new Error(`Commission rule ${index + 1}: Referrer Reward is required`);
287
- if (!r.refereeReward || r.refereeReward.value == null) throw new Error(`Commission rule ${index + 1}: Referee Reward is required`);
288
- if (r.referrerReward?.type === "percentage" && r.refereeReward?.type === "percentage") {
289
- if ((r.referrerReward.value || 0) + (r.refereeReward.value || 0) > 100) throw new Error(`Commission rule ${index + 1}: Referrer + Referee percentage cannot exceed 100%`);
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
- required: true,
369
- admin: { description: "Reward given to the partner who refers others" },
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
- required: true,
402
- admin: { description: "Discount given to the customer who was referred" },
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: _customerEmail, pluginConfig }) {
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 = Math.round(cartTotal * coupon.value / 100);
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: discount
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: Math.round(partnerCommission * 100) / 100,
671
- customerDiscount: Math.round(customerDiscount * 100) / 100
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: Math.round(partnerCommission * 100) / 100,
679
- customerDiscount: Math.round(customerDiscount * 100) / 100,
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?.referrerReward || !rule?.refereeReward) continue;
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.refereeReward.type === "percentage") itemCustomer = itemTotal * rule.refereeReward.value / 100;
706
- else itemCustomer = rule.refereeReward.value * quantity;
707
- if (rule.refereeReward.maxReward != null && itemCustomer > rule.refereeReward.maxReward) itemCustomer = rule.refereeReward.maxReward;
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 (cartValue !== void 0 && couponData.conditions) {
914
- const { minOrderValue, maxOrderValue } = couponData.conditions;
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 = Math.round(cartValue * couponData.value / 100);
928
- if (couponData.maxDiscountAmount && discount > couponData.maxDiscountAmount) discount = couponData.maxDiscountAmount;
929
- } else if (couponData.type === "fixed") discount = couponData.value;
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 ${totalCustomerDiscount.toFixed(2)} discount with this referral code`
1156
+ description: `Get ${roundedCustomerDiscount.toFixed(2)} discount with this referral code`
1008
1157
  },
1009
- partnerCommission: totalPartnerCommission,
1010
- customerDiscount: totalCustomerDiscount,
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;