@wtree/payload-ecommerce-coupon 3.78.7 → 3.78.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -422,23 +422,21 @@ const createReferralCodesCollection = (pluginConfig) => {
422
422
  };
423
423
  };
424
424
  //#endregion
425
- //#region src/utilities/roundTo2.ts
426
- /**
427
- * Rounds a number to 2 decimal places (standard for monetary values).
428
- */
429
- function roundTo2(value) {
430
- return Math.round(value * 100) / 100;
431
- }
432
- //#endregion
433
425
  //#region src/collections/createReferralProgramsCollection.ts
434
426
  function toNumber(value) {
435
427
  return typeof value === "number" && Number.isFinite(value) ? value : null;
436
428
  }
437
- function normalizeLegacyX100Money(value) {
438
- if (value == null) return null;
439
- if (Number.isInteger(value) && Math.abs(value) >= 1e3) return roundTo2(value / 100);
440
- return value;
441
- }
429
+ /**
430
+ * Scaling policy:
431
+ * - Admin inputs normal currency values (e.g. 100 means $100).
432
+ * - Internally, maxPartnerCommissionPerOrder, maxCustomerDiscountPerOrder, and
433
+ * minOrderAmount are stored in x100 (integer cents) form to avoid floating-point
434
+ * drift on monetary cap fields.
435
+ * - beforeChange → multiply by 100 before persisting.
436
+ * - afterRead → divide by 100 before returning to admin / calculation code.
437
+ * - partnerAmount / customerAmount per-rule fixed amounts are NOT scaled here;
438
+ * they represent per-item fixed currency amounts and are stored as-is.
439
+ */
442
440
  const createReferralProgramsCollection = (pluginConfig) => {
443
441
  const { collections, access, adminGroups, referralConfig, integration } = pluginConfig;
444
442
  const allowedTotalCommissionTypes = referralConfig.allowedTotalCommissionTypes;
@@ -461,88 +459,102 @@ const createReferralProgramsCollection = (pluginConfig) => {
461
459
  update: access.isAdmin || (() => false),
462
460
  delete: access.isAdmin || (() => false)
463
461
  },
464
- hooks: { beforeChange: [({ data }) => {
465
- if (!data.commissionRules || !Array.isArray(data.commissionRules) || data.commissionRules.length === 0) throw new APIError("At least one commission rule is required", 400);
466
- const maxPartnerCommissionPerOrder = normalizeLegacyX100Money(toNumber(data.maxPartnerCommissionPerOrder));
467
- if (maxPartnerCommissionPerOrder != null && maxPartnerCommissionPerOrder < 0) throw new APIError("Maximum commission per order for partner must be a non-negative number", 400);
468
- const maxCustomerDiscountPerOrder = normalizeLegacyX100Money(toNumber(data.maxCustomerDiscountPerOrder));
469
- if (maxCustomerDiscountPerOrder != null && maxCustomerDiscountPerOrder < 0) throw new APIError("Maximum discount for customer per order must be a non-negative number", 400);
470
- const minOrderAmount = normalizeLegacyX100Money(toNumber(data.minOrderAmount));
471
- if (minOrderAmount != null && minOrderAmount < 0) throw new APIError("Minimum Order Amount must be a non-negative number", 400);
472
- data.maxPartnerCommissionPerOrder = maxPartnerCommissionPerOrder != null ? maxPartnerCommissionPerOrder : null;
473
- data.maxCustomerDiscountPerOrder = maxCustomerDiscountPerOrder != null ? maxCustomerDiscountPerOrder : null;
474
- data.minOrderAmount = minOrderAmount ?? null;
475
- data.commissionRules = data.commissionRules.map((rule, index) => {
476
- const r = rule;
477
- if (!r.totalCommission) throw new APIError(`Commission rule ${index + 1}: Total Commission is required`, 400);
478
- if (!r.totalCommission.type || !allowedTotalCommissionTypes.includes(r.totalCommission.type)) throw new APIError(`Commission rule ${index + 1}: Total Commission type must be one of ${allowedTotalCommissionTypes.join(", ")}`, 400);
479
- const type = r.totalCommission.type;
480
- const appliesTo = r.appliesTo ?? "all";
481
- if (appliesTo === "products" && (!r.products || r.products.length === 0)) throw new APIError(`Commission rule ${index + 1}: At least one product is required`, 400);
482
- if ((appliesTo === "segments" || appliesTo === "categories") && (!r.categories || r.categories.length === 0) && (!r.tags || r.tags.length === 0)) throw new APIError(`Commission rule ${index + 1}: At least one category or tag is required`, 400);
483
- let partnerSplit;
484
- let customerSplit;
485
- let partnerPercent = null;
486
- let customerPercent = null;
487
- let partnerAmount = null;
488
- let customerAmount = null;
489
- let splitWarning = null;
490
- if (type === "percentage") {
491
- const partnerPctInput = toNumber(r.partnerPercent) ?? toNumber(r.partnerSplit);
492
- const customerPctInput = toNumber(r.customerPercent) ?? toNumber(r.customerSplit);
493
- if (partnerPctInput == null || partnerPctInput < 0 || partnerPctInput > 100) throw new APIError(`Commission rule ${index + 1}: Partner Split must be between 0 and 100`, 400);
494
- if (customerPctInput != null && (customerPctInput < 0 || customerPctInput > 100)) throw new APIError(`Commission rule ${index + 1}: Customer percentage must be between 0 and 100`, 400);
495
- const customerPctComputed = customerPctInput != null ? customerPctInput : 100 - partnerPctInput;
496
- const percentTotal = partnerPctInput + customerPctComputed;
497
- if (percentTotal > 100) throw new APIError(`Commission rule ${index + 1}: Partner percentage + Customer percentage cannot exceed 100`, 400);
498
- if (percentTotal > 50) splitWarning = `High total split configured: ${percentTotal}% (partner + customer).`;
499
- partnerPercent = partnerPctInput;
500
- customerPercent = customerPctComputed;
501
- partnerSplit = partnerPctInput;
502
- customerSplit = customerPctComputed;
503
- } else {
504
- const partnerAmountInput = normalizeLegacyX100Money(toNumber(r.partnerAmount));
505
- const customerAmountInput = normalizeLegacyX100Money(toNumber(r.customerAmount));
506
- const legacyPartnerSplitInput = toNumber(r.partnerSplit);
507
- const legacyCustomerSplitInput = toNumber(r.customerSplit);
508
- const hasNewFixedInputs = partnerAmountInput != null || customerAmountInput != null;
509
- const hasLegacyFixedInputs = legacyPartnerSplitInput != null || legacyCustomerSplitInput != null;
510
- if (hasNewFixedInputs) {
511
- if (partnerAmountInput == null || partnerAmountInput < 0) throw new APIError(`Commission rule ${index + 1}: Partner fixed amount must be a non-negative number`, 400);
512
- if (customerAmountInput == null || customerAmountInput < 0) throw new APIError(`Commission rule ${index + 1}: Customer fixed amount must be a non-negative number`, 400);
513
- partnerAmount = partnerAmountInput;
514
- customerAmount = customerAmountInput;
515
- partnerSplit = partnerAmountInput;
516
- customerSplit = customerAmountInput;
517
- } else if (hasLegacyFixedInputs) {
518
- if (legacyPartnerSplitInput == null || legacyPartnerSplitInput < 0) throw new APIError(`Commission rule ${index + 1}: For fixed commissions, both partner and customer values must be non-negative numbers`, 400);
519
- const resolvedLegacyCustomerSplit = legacyCustomerSplitInput ?? 100 - legacyPartnerSplitInput;
520
- if (resolvedLegacyCustomerSplit == null || resolvedLegacyCustomerSplit < 0) throw new APIError(`Commission rule ${index + 1}: For fixed commissions, both partner and customer values must be non-negative numbers`, 400);
521
- partnerSplit = legacyPartnerSplitInput;
522
- customerSplit = resolvedLegacyCustomerSplit;
523
- partnerAmount = null;
524
- customerAmount = null;
525
- } else throw new APIError(`Commission rule ${index + 1}: For fixed commissions, both partner and customer values must be provided`, 400);
526
- }
527
- return {
528
- ...rule,
529
- appliesTo: appliesTo === "categories" ? "segments" : appliesTo,
530
- totalCommission: {
531
- type,
532
- ...typeof r.totalCommission.value === "number" ? { value: r.totalCommission.value } : {},
533
- ...typeof r.totalCommission.maxAmount === "number" ? { maxAmount: r.totalCommission.maxAmount } : {}
534
- },
535
- partnerPercent,
536
- customerPercent,
537
- partnerAmount,
538
- customerAmount,
539
- partnerSplit,
540
- customerSplit,
541
- splitWarning
462
+ hooks: {
463
+ beforeChange: [({ data }) => {
464
+ if (!data.commissionRules || !Array.isArray(data.commissionRules) || data.commissionRules.length === 0) throw new APIError("At least one commission rule is required", 400);
465
+ const rawMaxPartner = toNumber(data.maxPartnerCommissionPerOrder);
466
+ if (rawMaxPartner != null && rawMaxPartner < 0) throw new APIError("Maximum commission per order for partner must be a non-negative number", 400);
467
+ const rawMaxCustomer = toNumber(data.maxCustomerDiscountPerOrder);
468
+ if (rawMaxCustomer != null && rawMaxCustomer < 0) throw new APIError("Maximum discount for customer per order must be a non-negative number", 400);
469
+ const rawMinOrder = toNumber(data.minOrderAmount);
470
+ if (rawMinOrder != null && rawMinOrder < 0) throw new APIError("Minimum Order Amount must be a non-negative number", 400);
471
+ data.maxPartnerCommissionPerOrder = rawMaxPartner != null ? Math.round(rawMaxPartner * 100) : null;
472
+ data.maxCustomerDiscountPerOrder = rawMaxCustomer != null ? Math.round(rawMaxCustomer * 100) : null;
473
+ data.minOrderAmount = rawMinOrder != null ? Math.round(rawMinOrder * 100) : null;
474
+ data.commissionRules = data.commissionRules.map((rule, index) => {
475
+ const r = rule;
476
+ if (!r.totalCommission) throw new APIError(`Commission rule ${index + 1}: Total Commission is required`, 400);
477
+ if (!r.totalCommission.type || !allowedTotalCommissionTypes.includes(r.totalCommission.type)) throw new APIError(`Commission rule ${index + 1}: Total Commission type must be one of ${allowedTotalCommissionTypes.join(", ")}`, 400);
478
+ const type = r.totalCommission.type;
479
+ const appliesTo = r.appliesTo ?? "all";
480
+ if (appliesTo === "products" && (!r.products || r.products.length === 0)) throw new APIError(`Commission rule ${index + 1}: At least one product is required`, 400);
481
+ if ((appliesTo === "segments" || appliesTo === "categories") && (!r.categories || r.categories.length === 0) && (!r.tags || r.tags.length === 0)) throw new APIError(`Commission rule ${index + 1}: At least one category or tag is required`, 400);
482
+ let partnerSplit;
483
+ let customerSplit;
484
+ let partnerPercent = null;
485
+ let customerPercent = null;
486
+ let partnerAmount = null;
487
+ let customerAmount = null;
488
+ let splitWarning = null;
489
+ if (type === "percentage") {
490
+ const partnerPctInput = toNumber(r.partnerPercent) ?? toNumber(r.partnerSplit);
491
+ const customerPctInput = toNumber(r.customerPercent) ?? toNumber(r.customerSplit);
492
+ if (partnerPctInput == null || partnerPctInput < 0 || partnerPctInput > 100) throw new APIError(`Commission rule ${index + 1}: Partner Split must be between 0 and 100`, 400);
493
+ if (customerPctInput != null && (customerPctInput < 0 || customerPctInput > 100)) throw new APIError(`Commission rule ${index + 1}: Customer percentage must be between 0 and 100`, 400);
494
+ const customerPctComputed = customerPctInput != null ? customerPctInput : 100 - partnerPctInput;
495
+ const percentTotal = partnerPctInput + customerPctComputed;
496
+ if (percentTotal > 100) throw new APIError(`Commission rule ${index + 1}: Partner percentage + Customer percentage cannot exceed 100`, 400);
497
+ if (percentTotal > 50) splitWarning = `High total split configured: ${percentTotal}% (partner + customer).`;
498
+ partnerPercent = partnerPctInput;
499
+ customerPercent = customerPctComputed;
500
+ partnerSplit = partnerPctInput;
501
+ customerSplit = customerPctComputed;
502
+ } else {
503
+ const partnerAmountInput = toNumber(r.partnerAmount);
504
+ const customerAmountInput = toNumber(r.customerAmount);
505
+ const legacyPartnerSplitInput = toNumber(r.partnerSplit);
506
+ const legacyCustomerSplitInput = toNumber(r.customerSplit);
507
+ const hasNewFixedInputs = partnerAmountInput != null || customerAmountInput != null;
508
+ const hasLegacyFixedInputs = legacyPartnerSplitInput != null || legacyCustomerSplitInput != null;
509
+ if (hasNewFixedInputs) {
510
+ if (partnerAmountInput == null || partnerAmountInput < 0) throw new APIError(`Commission rule ${index + 1}: Partner fixed amount must be a non-negative number`, 400);
511
+ if (customerAmountInput == null || customerAmountInput < 0) throw new APIError(`Commission rule ${index + 1}: Customer fixed amount must be a non-negative number`, 400);
512
+ partnerAmount = partnerAmountInput;
513
+ customerAmount = customerAmountInput;
514
+ partnerSplit = partnerAmountInput;
515
+ customerSplit = customerAmountInput;
516
+ } else if (hasLegacyFixedInputs) {
517
+ if (legacyPartnerSplitInput == null || legacyPartnerSplitInput < 0) throw new APIError(`Commission rule ${index + 1}: For fixed commissions, both partner and customer values must be non-negative numbers`, 400);
518
+ const resolvedLegacyCustomerSplit = legacyCustomerSplitInput ?? 100 - legacyPartnerSplitInput;
519
+ if (resolvedLegacyCustomerSplit == null || resolvedLegacyCustomerSplit < 0) throw new APIError(`Commission rule ${index + 1}: For fixed commissions, both partner and customer values must be non-negative numbers`, 400);
520
+ partnerSplit = legacyPartnerSplitInput;
521
+ customerSplit = resolvedLegacyCustomerSplit;
522
+ partnerAmount = null;
523
+ customerAmount = null;
524
+ } else throw new APIError(`Commission rule ${index + 1}: For fixed commissions, both partner and customer values must be provided`, 400);
525
+ }
526
+ return {
527
+ ...rule,
528
+ appliesTo: appliesTo === "categories" ? "segments" : appliesTo,
529
+ totalCommission: {
530
+ type,
531
+ ...typeof r.totalCommission.value === "number" ? { value: r.totalCommission.value } : {},
532
+ ...typeof r.totalCommission.maxAmount === "number" ? { maxAmount: r.totalCommission.maxAmount } : {}
533
+ },
534
+ partnerPercent,
535
+ customerPercent,
536
+ partnerAmount,
537
+ customerAmount,
538
+ partnerSplit,
539
+ customerSplit,
540
+ splitWarning
541
+ };
542
+ });
543
+ return data;
544
+ }],
545
+ afterRead: [({ doc }) => {
546
+ if (!doc) return doc;
547
+ const unscale = (value) => {
548
+ const n = toNumber(value);
549
+ if (n == null) return null;
550
+ return Math.round(n / 100 * 100) / 100;
542
551
  };
543
- });
544
- return data;
545
- }] },
552
+ doc.maxPartnerCommissionPerOrder = unscale(doc.maxPartnerCommissionPerOrder);
553
+ doc.maxCustomerDiscountPerOrder = unscale(doc.maxCustomerDiscountPerOrder);
554
+ doc.minOrderAmount = unscale(doc.minOrderAmount);
555
+ return doc;
556
+ }]
557
+ },
546
558
  fields: [
547
559
  {
548
560
  name: "name",
@@ -560,19 +572,19 @@ const createReferralProgramsCollection = (pluginConfig) => {
560
572
  name: "maxPartnerCommissionPerOrder",
561
573
  type: "number",
562
574
  min: 0,
563
- admin: { description: "Maximum commission per order for partner (normal currency value, ). Leave empty for no cap." }
575
+ admin: { description: "Maximum commission per order for partner (enter normal currency value, e.g. 50 for $50). Leave empty for no cap." }
564
576
  },
565
577
  {
566
578
  name: "maxCustomerDiscountPerOrder",
567
579
  type: "number",
568
580
  min: 0,
569
- admin: { description: "Maximum customer discount per order (normal currency value, ). Leave empty for no cap." }
581
+ admin: { description: "Maximum customer discount per order (enter normal currency value, e.g. 25 for $25). Leave empty for no cap." }
570
582
  },
571
583
  {
572
584
  name: "minOrderAmount",
573
585
  type: "number",
574
586
  min: 0,
575
- admin: { description: "Minimum cart subtotal required for this program (normal currency value, ). Leave empty for no minimum." }
587
+ admin: { description: "Minimum cart subtotal required for this program (enter normal currency value, e.g. 100 for $100). Leave empty for no minimum." }
576
588
  },
577
589
  {
578
590
  name: "commissionRules",
@@ -653,7 +665,7 @@ const createReferralProgramsCollection = (pluginConfig) => {
653
665
  max: 100,
654
666
  admin: {
655
667
  condition: (_, siblingData) => siblingData?.totalCommission?.type === "percentage",
656
- description: "Partner share in percent (0-100)"
668
+ description: "Partner share in percent (0100)"
657
669
  }
658
670
  },
659
671
  {
@@ -663,7 +675,7 @@ const createReferralProgramsCollection = (pluginConfig) => {
663
675
  max: 100,
664
676
  admin: {
665
677
  condition: (_, siblingData) => siblingData?.totalCommission?.type === "percentage",
666
- description: "Customer share percentage. (0-100). Partner + Customer cannot exceed 100."
678
+ description: "Customer share percentage (0100). Partner + Customer cannot exceed 100."
667
679
  }
668
680
  },
669
681
  {
@@ -672,7 +684,7 @@ const createReferralProgramsCollection = (pluginConfig) => {
672
684
  min: 0,
673
685
  admin: {
674
686
  condition: (_, siblingData) => siblingData?.totalCommission?.type === "fixed",
675
- description: "Fixed partner commission amount per item (normal currency value)."
687
+ description: "Fixed partner commission amount per item (normal currency value, e.g. 12.50 for $12.50)."
676
688
  }
677
689
  },
678
690
  {
@@ -681,7 +693,7 @@ const createReferralProgramsCollection = (pluginConfig) => {
681
693
  min: 0,
682
694
  admin: {
683
695
  condition: (_, siblingData) => siblingData?.totalCommission?.type === "fixed",
684
- description: "Fixed customer discount amount per item (normal currency value)."
696
+ description: "Fixed customer discount amount per item (normal currency value, e.g. 5 for $5)."
685
697
  }
686
698
  },
687
699
  {
@@ -690,7 +702,7 @@ const createReferralProgramsCollection = (pluginConfig) => {
690
702
  min: 0,
691
703
  admin: {
692
704
  hidden: true,
693
- description: "Canonical storage field. Percentage mode: percent. Fixed mode: amount."
705
+ description: "Canonical storage field. Percentage mode: percent value. Fixed mode: per-item amount."
694
706
  }
695
707
  },
696
708
  {
@@ -699,7 +711,7 @@ const createReferralProgramsCollection = (pluginConfig) => {
699
711
  min: 0,
700
712
  admin: {
701
713
  hidden: true,
702
- description: "Canonical storage field. Percentage mode: percent. Fixed mode: amount."
714
+ description: "Canonical storage field. Percentage mode: percent value. Fixed mode: per-item amount."
703
715
  }
704
716
  }
705
717
  ]
@@ -743,16 +755,35 @@ function getCartItemUnitPrice({ item, product, variant, currencyCode, defaultCur
743
755
  }
744
756
  //#endregion
745
757
  //#region src/utilities/calculateValues.ts
758
+ /** Convert a normal-currency amount to integer cents. */
759
+ function toCents(amount) {
760
+ return Math.round(amount * 100);
761
+ }
762
+ /** Convert integer cents back to a normal-currency amount (2 dp max). */
763
+ function fromCents(cents) {
764
+ return Math.round(cents) / 100;
765
+ }
766
+ /**
767
+ * Calculate the discount amount for a coupon.
768
+ *
769
+ * @param coupon - Coupon document from DB (values in normal currency).
770
+ * @param cartTotal - Cart subtotal in normal currency.
771
+ * @returns Discount amount in normal currency (2 dp).
772
+ */
746
773
  function calculateCouponDiscount({ coupon, cartTotal }) {
747
- let discount = 0;
774
+ const cartCents = toCents(cartTotal);
775
+ let discountCents = 0;
748
776
  if (coupon.type === "percentage") {
749
- discount = roundTo2(cartTotal * coupon.value / 100);
750
- if (coupon.maxDiscountAmount != null && discount > coupon.maxDiscountAmount) discount = roundTo2(coupon.maxDiscountAmount);
777
+ discountCents = Math.floor(cartCents * coupon.value / 100);
778
+ if (coupon.maxDiscountAmount != null) {
779
+ const maxCents = toCents(coupon.maxDiscountAmount);
780
+ if (discountCents > maxCents) discountCents = maxCents;
781
+ }
751
782
  } else if (coupon.type === "fixed") {
752
- discount = roundTo2(coupon.value);
753
- if (discount > cartTotal) discount = roundTo2(cartTotal);
783
+ discountCents = toCents(coupon.value);
784
+ if (discountCents > cartCents) discountCents = cartCents;
754
785
  }
755
- return roundTo2(discount);
786
+ return fromCents(discountCents);
756
787
  }
757
788
  function relationId$5(value) {
758
789
  if (value == null) return null;
@@ -773,19 +804,25 @@ function getRuleSplits(rule) {
773
804
  customerSplit: typeof rule.customerSplit === "number" ? rule.customerSplit : typeof rule.refereeSplit === "number" ? rule.refereeSplit : 100 - partnerRaw
774
805
  };
775
806
  }
776
- function calculateItemRewardByRule({ rule, itemTotal, quantity, allowedTotalCommissionTypes }) {
807
+ /**
808
+ * Calculate partner and customer reward for a single line item.
809
+ *
810
+ * ALL inputs are expected in CENTS.
811
+ * Returns rewards in CENTS, or null if the rule is inapplicable.
812
+ */
813
+ function calculateItemRewardByRule({ rule, itemTotalCents, quantity, allowedTotalCommissionTypes }) {
777
814
  const allowedTypes = allowedCommissionTypesSet(allowedTotalCommissionTypes);
778
815
  if (rule.totalCommission) {
779
816
  if (!allowedTypes.has(rule.totalCommission.type)) return null;
780
- const resolvedMaxAmount = typeof rule.totalCommission.maxAmount === "number" && Number.isFinite(rule.totalCommission.maxAmount) ? rule.totalCommission.maxAmount : null;
817
+ const resolvedMaxAmountCents = typeof rule.totalCommission.maxAmount === "number" && Number.isFinite(rule.totalCommission.maxAmount) ? toCents(rule.totalCommission.maxAmount) : null;
781
818
  if (rule.totalCommission.type === "fixed" && rule.totalCommission.value == null) {
782
- const partnerAmtPerUnit = typeof rule.partnerSplit === "number" ? rule.partnerSplit : null;
783
- const customerAmtPerUnit = typeof rule.customerSplit === "number" ? rule.customerSplit : null;
784
- if (partnerAmtPerUnit == null || customerAmtPerUnit == null) return null;
785
- let partner = partnerAmtPerUnit * quantity;
786
- let customer = customerAmtPerUnit * quantity;
787
- if (resolvedMaxAmount != null) {
788
- const maxPotForLine = resolvedMaxAmount * quantity;
819
+ const partnerAmtPerUnitCents = typeof rule.partnerSplit === "number" ? toCents(rule.partnerSplit) : null;
820
+ const customerAmtPerUnitCents = typeof rule.customerSplit === "number" ? toCents(rule.customerSplit) : null;
821
+ if (partnerAmtPerUnitCents == null || customerAmtPerUnitCents == null) return null;
822
+ let partner = partnerAmtPerUnitCents * quantity;
823
+ let customer = customerAmtPerUnitCents * quantity;
824
+ if (resolvedMaxAmountCents != null) {
825
+ const maxPotForLine = resolvedMaxAmountCents * quantity;
789
826
  const totalPot = partner + customer;
790
827
  if (totalPot > maxPotForLine && totalPot > 0) {
791
828
  const ratio = maxPotForLine / totalPot;
@@ -798,7 +835,6 @@ function calculateItemRewardByRule({ rule, itemTotal, quantity, allowedTotalComm
798
835
  customer
799
836
  };
800
837
  }
801
- let totalPot = 0;
802
838
  if (rule.totalCommission.type === "percentage") {
803
839
  const commissionValue = typeof rule.totalCommission.value === "number" && Number.isFinite(rule.totalCommission.value) ? rule.totalCommission.value : null;
804
840
  if (commissionValue == null) {
@@ -806,58 +842,63 @@ function calculateItemRewardByRule({ rule, itemTotal, quantity, allowedTotalComm
806
842
  if (partnerPercentInput == null || partnerPercentInput < 0 || partnerPercentInput > 100) return null;
807
843
  const customerPercentInput = typeof rule.customerPercent === "number" ? rule.customerPercent : typeof rule.customerSplit === "number" ? rule.customerSplit : 100 - partnerPercentInput;
808
844
  if (customerPercentInput == null || customerPercentInput < 0 || customerPercentInput > 100) return null;
809
- const partner = itemTotal * partnerPercentInput / 100;
810
- const customer = itemTotal * customerPercentInput / 100;
811
- if (resolvedMaxAmount != null) {
812
- const maxPotForLine = resolvedMaxAmount * quantity;
845
+ let partner = Math.floor(itemTotalCents * partnerPercentInput / 100);
846
+ let customer = Math.floor(itemTotalCents * customerPercentInput / 100);
847
+ if (resolvedMaxAmountCents != null) {
848
+ const maxPotForLine = resolvedMaxAmountCents * quantity;
813
849
  const totalForLine = partner + customer;
814
850
  if (totalForLine > maxPotForLine && totalForLine > 0) {
815
851
  const ratio = maxPotForLine / totalForLine;
816
- return {
817
- partner: Math.floor(partner * ratio),
818
- customer: Math.floor(customer * ratio)
819
- };
852
+ partner = Math.floor(partner * ratio);
853
+ customer = Math.floor(customer * ratio);
820
854
  }
821
855
  }
822
856
  return {
823
- partner: Math.floor(partner),
824
- customer: Math.floor(customer)
857
+ partner,
858
+ customer
825
859
  };
826
860
  }
827
- totalPot = itemTotal * commissionValue / 100;
828
- } else {
861
+ let totalPotCents = Math.floor(itemTotalCents * commissionValue / 100);
862
+ if (resolvedMaxAmountCents != null) {
863
+ const maxPotForLine = resolvedMaxAmountCents * quantity;
864
+ if (totalPotCents > maxPotForLine) totalPotCents = maxPotForLine;
865
+ }
829
866
  const splits = getRuleSplits(rule);
830
867
  if (!splits) return null;
831
- totalPot = rule.totalCommission.value * quantity;
832
- if (resolvedMaxAmount != null) {
833
- const maxPotForLine = resolvedMaxAmount * quantity;
834
- if (totalPot > maxPotForLine) totalPot = maxPotForLine;
835
- }
836
868
  return {
837
- partner: Math.floor(totalPot * splits.partnerSplit / 100),
838
- customer: Math.floor(totalPot * splits.customerSplit / 100)
869
+ partner: Math.floor(totalPotCents * splits.partnerSplit / 100),
870
+ customer: Math.floor(totalPotCents * splits.customerSplit / 100)
839
871
  };
840
872
  }
841
- if (resolvedMaxAmount != null) {
842
- const maxPotForLine = resolvedMaxAmount * quantity;
843
- if (totalPot > maxPotForLine) totalPot = maxPotForLine;
873
+ {
874
+ const splits = getRuleSplits(rule);
875
+ if (!splits) return null;
876
+ let totalPotCents = toCents(rule.totalCommission.value) * quantity;
877
+ if (resolvedMaxAmountCents != null) {
878
+ const maxPotForLine = resolvedMaxAmountCents * quantity;
879
+ if (totalPotCents > maxPotForLine) totalPotCents = maxPotForLine;
880
+ }
881
+ return {
882
+ partner: Math.floor(totalPotCents * splits.partnerSplit / 100),
883
+ customer: Math.floor(totalPotCents * splits.customerSplit / 100)
884
+ };
844
885
  }
845
- const splits = getRuleSplits(rule);
846
- if (!splits) return null;
847
- return {
848
- partner: Math.floor(totalPot * splits.partnerSplit / 100),
849
- customer: Math.floor(totalPot * splits.customerSplit / 100)
850
- };
851
886
  }
852
887
  if (rule.referrerReward && rule.refereeReward) {
853
888
  let partner = 0;
854
- if (rule.referrerReward.type === "percentage") partner = itemTotal * rule.referrerReward.value / 100;
855
- else partner = rule.referrerReward.value * quantity;
856
- if (rule.referrerReward.maxReward != null && partner > rule.referrerReward.maxReward) partner = rule.referrerReward.maxReward;
889
+ if (rule.referrerReward.type === "percentage") partner = Math.floor(itemTotalCents * rule.referrerReward.value / 100);
890
+ else partner = toCents(rule.referrerReward.value) * quantity;
891
+ if (rule.referrerReward.maxReward != null) {
892
+ const maxCents = toCents(rule.referrerReward.maxReward);
893
+ if (partner > maxCents) partner = maxCents;
894
+ }
857
895
  let customer = 0;
858
- if (rule.refereeReward.type === "percentage") customer = itemTotal * rule.refereeReward.value / 100;
859
- else customer = rule.refereeReward.value * quantity;
860
- if (rule.refereeReward.maxReward != null && customer > rule.refereeReward.maxReward) customer = rule.refereeReward.maxReward;
896
+ if (rule.refereeReward.type === "percentage") customer = Math.floor(itemTotalCents * rule.refereeReward.value / 100);
897
+ else customer = toCents(rule.refereeReward.value) * quantity;
898
+ if (rule.refereeReward.maxReward != null) {
899
+ const maxCents = toCents(rule.refereeReward.maxReward);
900
+ if (customer > maxCents) customer = maxCents;
901
+ }
861
902
  return {
862
903
  partner,
863
904
  customer
@@ -873,12 +914,12 @@ function getItemCategoryIds(item) {
873
914
  function getItemTagIds(item) {
874
915
  return Array.isArray(item?.product?.tags) ? normalizeIds(item.product.tags) : [];
875
916
  }
876
- function selectBestRuleForItem({ rules, item, itemTotal, quantity, cartTotal, minOrderAmount, allowedTotalCommissionTypes }) {
917
+ function selectBestRuleForItem({ rules, item, itemTotalCents, quantity, cartTotalCents, minOrderAmountCents, allowedTotalCommissionTypes }) {
877
918
  const allowedTypes = allowedCommissionTypesSet(allowedTotalCommissionTypes);
878
919
  const eligibleRules = rules.filter((rule) => {
879
920
  if (!(rule?.totalCommission?.type ? allowedTypes.has(rule.totalCommission.type) : true)) return false;
880
- const resolvedMinOrderAmount = typeof minOrderAmount === "number" && Number.isFinite(minOrderAmount) ? minOrderAmount : typeof rule?.minOrderAmount === "number" && Number.isFinite(rule.minOrderAmount) ? rule.minOrderAmount : null;
881
- if (resolvedMinOrderAmount != null) return cartTotal >= resolvedMinOrderAmount;
921
+ const resolvedMinCents = minOrderAmountCents != null && Number.isFinite(minOrderAmountCents) ? minOrderAmountCents : typeof rule?.minOrderAmount === "number" && Number.isFinite(rule.minOrderAmount) ? toCents(rule.minOrderAmount) : null;
922
+ if (resolvedMinCents != null) return cartTotalCents >= resolvedMinCents;
882
923
  return true;
883
924
  });
884
925
  const productId = relationId$5(item.product);
@@ -901,7 +942,7 @@ function selectBestRuleForItem({ rules, item, itemTotal, quantity, cartTotal, mi
901
942
  for (const rule of candidates) {
902
943
  const reward = calculateItemRewardByRule({
903
944
  rule,
904
- itemTotal,
945
+ itemTotalCents,
905
946
  quantity,
906
947
  allowedTotalCommissionTypes
907
948
  });
@@ -927,6 +968,10 @@ function selectBestRuleForItem({ rules, item, itemTotal, quantity, cartTotal, mi
927
968
  }
928
969
  return best;
929
970
  }
971
+ /**
972
+ * Returns the effective minimum order amount for a program in NORMAL CURRENCY.
973
+ * Returns null if there is no minimum.
974
+ */
930
975
  function getProgramMinimumOrderAmount({ program, allowedTotalCommissionTypes }) {
931
976
  if (typeof program?.minOrderAmount === "number" && Number.isFinite(program.minOrderAmount)) return program.minOrderAmount;
932
977
  const rules = Array.isArray(program?.commissionRules) ? program.commissionRules : [];
@@ -939,50 +984,66 @@ function getProgramMinimumOrderAmount({ program, allowedTotalCommissionTypes })
939
984
  if (!minValues.length) return null;
940
985
  return Math.min(...minValues);
941
986
  }
987
+ /**
988
+ * Calculate total partner commission and customer discount for a cart.
989
+ *
990
+ * All monetary inputs are in NORMAL CURRENCY.
991
+ * Returns results in NORMAL CURRENCY (2 dp).
992
+ */
942
993
  function calculateCommissionAndDiscount({ cartItems, program, currencyCode = "AED", cartTotal = 0, allowedTotalCommissionTypes }) {
943
994
  const rules = Array.isArray(program?.commissionRules) ? program.commissionRules : [];
944
995
  if (!rules.length) return {
945
996
  partnerCommission: 0,
946
997
  customerDiscount: 0
947
998
  };
948
- let totalPartnerCommission = 0;
949
- let totalCustomerDiscount = 0;
999
+ const cartTotalCents = toCents(cartTotal);
1000
+ const programMinOrderAmountCents = typeof program?.minOrderAmount === "number" && Number.isFinite(program.minOrderAmount) ? toCents(program.minOrderAmount) : null;
1001
+ let totalPartnerCents = 0;
1002
+ let totalCustomerCents = 0;
950
1003
  for (const item of cartItems) {
951
1004
  const product = typeof item.product === "object" ? item.product : {};
952
- const itemPrice = getCartItemUnitPrice({
1005
+ const itemPriceCurrency = getCartItemUnitPrice({
953
1006
  item,
954
1007
  product,
955
1008
  variant: typeof item.variant === "object" ? item.variant : {},
956
1009
  currencyCode
957
1010
  });
958
1011
  const quantity = item.quantity ?? 1;
959
- const itemTotal = itemPrice * quantity;
1012
+ const itemTotalCents = toCents(itemPriceCurrency) * quantity;
960
1013
  const bestMatch = selectBestRuleForItem({
961
1014
  rules,
962
1015
  item: {
963
1016
  ...item,
964
1017
  product
965
1018
  },
966
- itemTotal,
1019
+ itemTotalCents,
967
1020
  quantity,
968
- cartTotal,
969
- minOrderAmount: typeof program?.minOrderAmount === "number" && Number.isFinite(program.minOrderAmount) ? program.minOrderAmount : null,
1021
+ cartTotalCents,
1022
+ minOrderAmountCents: programMinOrderAmountCents,
970
1023
  allowedTotalCommissionTypes
971
1024
  });
972
1025
  if (!bestMatch) continue;
973
- totalPartnerCommission += bestMatch.reward.partner;
974
- totalCustomerDiscount += bestMatch.reward.customer;
1026
+ totalPartnerCents += bestMatch.reward.partner;
1027
+ totalCustomerCents += bestMatch.reward.customer;
975
1028
  }
976
- const maxPartnerCommissionPerOrder = typeof program?.maxPartnerCommissionPerOrder === "number" && Number.isFinite(program.maxPartnerCommissionPerOrder) ? program.maxPartnerCommissionPerOrder : null;
977
- const maxCustomerDiscountPerOrder = typeof program?.maxCustomerDiscountPerOrder === "number" && Number.isFinite(program.maxCustomerDiscountPerOrder) ? program.maxCustomerDiscountPerOrder : null;
978
- if (maxPartnerCommissionPerOrder != null) totalPartnerCommission = Math.min(totalPartnerCommission, maxPartnerCommissionPerOrder);
979
- if (maxCustomerDiscountPerOrder != null) totalCustomerDiscount = Math.min(totalCustomerDiscount, maxCustomerDiscountPerOrder);
1029
+ const maxPartnerCents = typeof program?.maxPartnerCommissionPerOrder === "number" && Number.isFinite(program.maxPartnerCommissionPerOrder) ? toCents(program.maxPartnerCommissionPerOrder) : null;
1030
+ const maxCustomerCents = typeof program?.maxCustomerDiscountPerOrder === "number" && Number.isFinite(program.maxCustomerDiscountPerOrder) ? toCents(program.maxCustomerDiscountPerOrder) : null;
1031
+ if (maxPartnerCents != null) totalPartnerCents = Math.min(totalPartnerCents, maxPartnerCents);
1032
+ if (maxCustomerCents != null) totalCustomerCents = Math.min(totalCustomerCents, maxCustomerCents);
980
1033
  return {
981
- partnerCommission: totalPartnerCommission,
982
- customerDiscount: totalCustomerDiscount
1034
+ partnerCommission: fromCents(totalPartnerCents),
1035
+ customerDiscount: fromCents(totalCustomerCents)
983
1036
  };
984
1037
  }
985
1038
  //#endregion
1039
+ //#region src/utilities/roundTo2.ts
1040
+ /**
1041
+ * Rounds a number to 2 decimal places (standard for monetary values).
1042
+ */
1043
+ function roundTo2(value) {
1044
+ return Math.round(value * 100) / 100;
1045
+ }
1046
+ //#endregion
986
1047
  //#region src/endpoints/applyCoupon.ts
987
1048
  function relationId$4(value) {
988
1049
  if (value == null) return null;
@@ -1175,17 +1236,17 @@ async function handleCouponCode({ payload, cart, cartID, normalizedCode, custome
1175
1236
  }, { status: 400 });
1176
1237
  const cartSubtotal = Number(resolvers.getCartSubtotal(cart)) || 0;
1177
1238
  const cartTotal = Number(resolvers.getCartTotal(cart)) || cartSubtotal || 0;
1178
- if (coupon.minOrderValue && cartTotal < coupon.minOrderValue) return Response.json({
1239
+ if (coupon.minOrderValue && cartSubtotal < coupon.minOrderValue) return Response.json({
1179
1240
  success: false,
1180
1241
  error: `Minimum order value of ${coupon.minOrderValue} ${pluginConfig.defaultCurrency} required`
1181
1242
  }, { status: 400 });
1182
- if (coupon.maxOrderValue && cartTotal > coupon.maxOrderValue) return Response.json({
1243
+ if (coupon.maxOrderValue && cartSubtotal > coupon.maxOrderValue) return Response.json({
1183
1244
  success: false,
1184
1245
  error: `Maximum order value of ${coupon.maxOrderValue} ${pluginConfig.defaultCurrency} exceeded`
1185
1246
  }, { status: 400 });
1186
1247
  const discountAmount = calculateCouponDiscount({
1187
1248
  coupon,
1188
- cartTotal
1249
+ cartTotal: cartSubtotal
1189
1250
  });
1190
1251
  const nextTotal = roundTo2(Math.max(0, cartTotal - discountAmount));
1191
1252
  const data = {};
@@ -1247,12 +1308,13 @@ async function handleReferralCode({ payload, cart, cartID, normalizedCode, plugi
1247
1308
  error: "Referral code already applied to this cart"
1248
1309
  }, { status: 400 });
1249
1310
  const cartItems = resolvers.getCartItems(cart);
1250
- const cartTotal = Number(resolvers.getCartTotal(cart)) || Number(resolvers.getCartSubtotal(cart)) || 0;
1311
+ const cartSubtotal = Number(resolvers.getCartSubtotal(cart)) || 0;
1312
+ const cartTotal = Number(resolvers.getCartTotal(cart)) || cartSubtotal || 0;
1251
1313
  const minOrderAmount = getProgramMinimumOrderAmount({
1252
1314
  program,
1253
1315
  allowedTotalCommissionTypes: pluginConfig.referralConfig.allowedTotalCommissionTypes
1254
1316
  });
1255
- if (typeof minOrderAmount === "number" && cartTotal < minOrderAmount) return Response.json({
1317
+ if (typeof minOrderAmount === "number" && cartSubtotal < minOrderAmount) return Response.json({
1256
1318
  success: false,
1257
1319
  error: `Minimum order value of ${minOrderAmount} ${pluginConfig.defaultCurrency} required for this referral program`
1258
1320
  }, { status: 400 });
@@ -1260,7 +1322,7 @@ async function handleReferralCode({ payload, cart, cartID, normalizedCode, plugi
1260
1322
  cartItems,
1261
1323
  program,
1262
1324
  currencyCode: pluginConfig.defaultCurrency,
1263
- cartTotal,
1325
+ cartTotal: cartSubtotal,
1264
1326
  allowedTotalCommissionTypes: pluginConfig.referralConfig.allowedTotalCommissionTypes
1265
1327
  });
1266
1328
  const roundedPartnerCommission = roundTo2(partnerCommission);
@@ -1689,7 +1751,7 @@ async function validateReferralCode({ payload, normalizedCode, cartID, pluginCon
1689
1751
  id: cartID,
1690
1752
  depth: 2
1691
1753
  }) : null;
1692
- const cartTotal = cart ? Number(resolvers.getCartTotal(cart)) || Number(resolvers.getCartSubtotal(cart)) || 0 : 0;
1754
+ const cartTotal = cart ? Number(resolvers.getCartSubtotal(cart)) || Number(resolvers.getCartTotal(cart)) || 0 : 0;
1693
1755
  const minOrderAmount = getProgramMinimumOrderAmount({
1694
1756
  program,
1695
1757
  allowedTotalCommissionTypes: pluginConfig.referralConfig.allowedTotalCommissionTypes