Abstrakt image hero
Home/Blog/Platform-funded discounts in Medusa.js — loyalty points and newsletter signup without charging the seller

Platform-funded discounts in Medusa.js — loyalty points and newsletter signup without charging the seller

Arkadiusz Wawrzyniak

Arkadiusz Wawrzyniak

June 10, 2026 - 85 views - 6 min read

Share

In a regular store, implementing a discount is straightforward: the customer pays less, end of story. In Medusa.js, standard promotions are a native module, but
in a marketplace things get complicated quickly and custom solutions are needed — because a seller enters the picture, and there is one question you need to ask before you write the first line of code: who funds this discount?

That question forced me into a non-standard architecture when building new features at Artovnia.com.
In this post I describe how I implemented two platform-funded discounts — loyalty points and newsletter signup — without passing the cost of the platform's promotion on to sellers.

Finances and a problem easy to overlook

Imagine a simple scenario:

  • the product costs 100 PLN,
  • the platform commission is 20%,
  • the platform offers new newsletter subscribers a 5% discount.

Naive implementation: I add a line item adjustment with code NEWSLETTER_SIGNUP, item.total drops to 95 PLN. Done.

But now let's calculate the seller payout:

Bash
prowizja = 95 × 20% = 19 PLN
payout sprzedawcy = 95 - 19 = 76 PLN

Without the newsletter discount the seller would have received 100 - 20 = 80 PLN.

The platform offers a discount promoting its own newsletter, and the seller loses 4 PLN — with no notification, no consent, no justification. This is not a library bug. It is a consequence of the fact that the standard discount mechanism in every cart system — including Medusa — operates on item.total, not on commission.

Two-step architecture

To prevent the seller from funding the platform's discount, every platform-funded discount must go through two steps:

Step 1 — when calculating the commission: add the discount back into the commission base.

Step 2 — after the commission has been calculated: subtract the discount from the platform's commission line.

Net result with a 5 PLN newsletter discount:

Bash
commission base = 95 + 5 = 100 PLN
prowizja = 100 × 20% = 20 PLN
prowizja po korekcie = 20 - 5 = 15 PLN
payout sprzedawcy = 95 - 15 = 80 PLN ✓

The seller receives the same amount as without the discount. The customer pays less. The platform funds the difference from its own commission. Everyone wins.

One definition, two mechanisms

I implemented two types of platform-funded discounts: LOYALTY_POINTS and NEWSLETTER_SIGNUP. To avoid scattering the logic across the entire codebase — checkout, commission, payout — I defined them once in a single place:

typescript
export const PLATFORM_FUNDED_ADJUSTMENT_CODES = [
  'LOYALTY_POINTS',
  'NEWSLETTER_SIGNUP',
] as const

export const sumPlatformFundedAdjustments = (
  adjustments,
  options?: { includeCodes?: PlatformFundedAdjustmentCode[] }
) => {
  return roundPlatformCurrency(
    adjustments.reduce((sum, adjustment) => {
      if (!isPlatformFundedAdjustmentCode(adjustment?.code)) return sum
      if (
        options?.includeCodes &&
        !options.includeCodes.includes(adjustment.code)
      ) return sum
      return sum + Math.max(0, toPlatformAmountNumber(adjustment.amount))
    }, 0)
  )
}

Checkout can still reduce item.total as normal. However, every place that calculates commission or payout knows which codes are platform-funded — and treats them differently.

Add-back in calculate-commission-lines

In calculate-commission-lines.ts before calculating the commission for each item I add the platform-funded adjustments back in:

typescript
const platformFundedDiscountForItem = MathBN.convert(
  sumPlatformFundedAdjustments(item.adjustments)
)

const itemForCommission = {
  ...item,
  total: MathBN.add(
    MathBN.convert(item.total),
    platformFundedDiscountForItem
  ),
}

const commissionValue = await calculateCommissionValue(
  commissionRule.rate,
  itemForCommission,
  order.currency_code,
  container
)

Regular seller discounts still reduce the commission base — because they affect the product price. Only platform-funded adjustments are neutralised at this stage.

VAT-aware commission adjustment

This is the piece of logic that is easy to get wrong, yet its importance is critical.

The customer's discount is a gross amount — the customer paid 5 PLN less. However, commission_line.value may be a net amount (when include_tax = false). If I subtracted 5 PLN gross from a net commission, the platform would give back more than it should.

Example at 23% VAT:

Bash
odjęcie 30 PLN od prowizji netto
= realna utrata 36,90 PLN prowizji brutto

That is why the finalizer must distinguish between both cases:

typescript
export const calculatePlatformFundedCommissionAdjustment = ({
  commissionBefore,
  fundedDiscountAmount,
  includeTax,
  taxRate,
}) => {
  const taxMultiplier = 1 + taxRate

  if (includeTax) {
    // commission_line.value jest brutto — odejmujemy 1:1
    const appliedDiscount = Math.min(fundedDiscountAmount, commissionBefore)
    return {
      appliedDiscount,
      commissionAfter: commissionBefore - appliedDiscount,
      commissionReductionAmount: appliedDiscount,
    }
  }

  // commission_line.value jest netto — przeliczamy rabat przez VAT
  const commissionBeforeGross = commissionBefore * taxMultiplier
  const appliedDiscount = Math.min(fundedDiscountAmount, commissionBeforeGross)
  const commissionReductionAmount = appliedDiscount / taxMultiplier

  return {
    appliedDiscount,
    commissionAfter: commissionBefore - commissionReductionAmount,
    commissionReductionAmount,
  }
}

A real numeric example from a manual test:

Bash
commission net before:    40.00
commission gross before:  49.20  (VAT 23%)
platform-funded discount: 30.00 brutto
---
commission gross after:   19.20
commission net after:     15.61
VAT after:                 3.59

Audit before mutation

The finalizer first writes an audit record to platform_commission_adjustment, and only then updates the commission line. The order is intentional — if the update fails, you have a trace. If the audit fails, you have no mutation without a trace.

typescript
await commissionService.createPlatformCommissionAdjustments({
  source_type: sourceType,        // 'newsletter_signup' | 'loyalty_points'
  order_id: order.id,
  item_line_id: itemLineId,
  target_commission_line_id: commissionLine.id,
  discount_amount: appliedDiscount,
  commission_before: commissionBefore,
  commission_after: commissionAfter,
  status,
  idempotency_key: idempotencyKey,
  metadata: {
    commission_discount_mode: 'gross_discount_to_commission_value',
    commission_reduction_amount: commissionReductionAmount,
    commission_before_gross: commissionBeforeGross,
    commission_after_gross: commissionAfterGross,
    include_tax: includeTax,
    tax_rate: taxRate,
  },
})

await commissionService.updateCommissionLines({
  id: commissionLine.id,
  value: commissionAfter,
})

Every commission adjustment has a clear audit trail: where the discount came from, what the commission was before and after, and which VAT rate was applied.

Idempotency and retry

The order lifecycle in Medusa is an environment where hooks can be re-executed. Every adjustment has a deterministic key:

typescript
const idempotencyKey =
  `platform_commission_adjustment:${sourceType}:${order.id}:${itemLineId}:${idempotencyIdentity}`

When the finalizer runs again I do not subtract the commission a second time. Instead I repair the commission line to the value recorded in the audit:

typescript
if (existingAdjustment) {
  await commissionService.updateCommissionLines({
    id: existingAdjustment.target_commission_line_id,
    value: toPlatformAmountNumber(existingAdjustment.commission_after),
  })
  continue
}

Retries and replays become safe. This is especially important because create-commission-lines can recreate commission lines on the order side — the finalizer must survive that without applying a double adjustment.

Loyalty — points stay in their own domain

Both mechanisms share the same commission finalizer, but loyalty has its own points logic completely isolated from commission. Each layer does its own job — and only its own job.

typescript
const commissionAdjustmentResult =
  await applyPlatformFundedCommissionAdjustments({
    orderId: order.id,
    container,
    sourceType: 'loyalty_points',
    adjustmentCodes: ['LOYALTY_POINTS'],
    metadataKey: 'loyalty',
  })

// Punkty na podstawie faktycznie zastosowanego rabatu
const normalizedRedeemedPoints = resolveRedeemedPointsForAppliedDiscount({
  appliedDiscount: commissionAdjustmentResult.appliedDiscountTotal,
  configuredDiscountAmount,
  configuredAppliedPoints,
  pointsPerCurrencyUnit,
  redeemablePoints,
})

await loyaltyService.createLoyaltyTransactionRecord({
  customer_id: order.customer_id,
  order_id: order.id,
  type: 'redeem',
  points: -normalizedRedeemedPoints,
  amount: commissionAdjustmentResult.appliedDiscountTotal,
  reason: 'order_placed_redeem',
  idempotency_key: `loyalty_redeem_order_${order.id}`,
})

Separation of concerns: the commission finalizer knows how to adjust the commission line. The loyalty module knows how to manage points and account balance. They are connected by the order ID and the audit record in platform_commission_adjustment.

Safety cap

Not every points amount should be redeemable. A platform-funded discount cannot exceed the platform's commission — otherwise the platform would be subsidising beyond its own margin, or the seller payout would become unstable.

When the discount is applied to the cart, the maximum commission for that order is estimated. This is the upper limit for the total platform-funded adjustments. If the customer wants to redeem more points than the safety cap allows — the system trims the discount, it does not error. Silently, deterministically, without an exception.

Newsletter: one-time guarantee via database constraint

The newsletter welcome code is a one-time entitlement per subscriber/email. Beyond application-level logic, the one-time guarantee is enforced at the database level — because application logic fails under race conditions:

Bash
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_newsletter_welcome_discount_owner"
ON "newsletter_welcome_discount" ("subscriber_id", lower("email"))
WHERE "deleted_at" IS NULL;

With concurrent requests (double-click, race condition) the database rejects the second insert. The service handles the conflict idempotently — it reads the existing record and treats the request as a repeat.

Full numeric example

Bash
Produkty:           400.00 PLN
Dostawa:             25.00 PLN
Loyalty discount:    30.00 PLN
Klient płaci:       395.00 PLN

Commission base:    400.00 PLN  (add-back loyalty)
Commission net:      40.00 PLN
Commission gross:    49.20 PLN  (VAT 23%)

Po korekcie loyalty:
Commission gross:    19.20 PLN
Commission net:      15.61 PLN
VAT:                  3.59 PLN

Payout sprzedawcy:
370.00 - 19.20 + 25.00 = 375.80 PLN

Without the loyalty discount the seller would have received:

Bash
400.00 - 49.20 + 25.00 = 375.80 PLN

Identical. That is what a good abstraction should look like.

Summary

The standard discount in Medusa (and in virtually every other e-commerce framework) reduces item.total. In a marketplace that is not enough — someone has to pay for the difference, and by default that someone is the seller.

The solution is two-step: add-back to the commission base, then adjust the commission line after the commission has been calculated. On top of that:

  • a single definition of platform-funded codes as the source of truth for checkout, commission, and payout,
  • converting the gross discount through VAT when adjusting a net commission line,
  • audit before mutation, idempotency key protecting against double adjustment on retry,
  • safety cap ensuring the discount never exceeds the platform's commission,
  • a unique database constraint for one-time entitlements.

This is the kind of implementation you do not get by installing a plugin.

I build custom solutions on Medusa.js — from module architecture through integrations to full marketplaces.

FAQ

Frequently asked questions

A full refund within the return window is clean — the payout hasn't been disbursed yet, so there's nothing to reverse. For a partial refund, the system excludes commission lines for returned items from the payout rather than recreating them. The finalizer handles commission line rebuilds: if a line was rebuilt after a retry, the audit record is reattached to the new line with the correct value.

Every commission adjustment has a deterministic idempotency key. On a repeated invocation, the finalizer doesn't subtract the discount a second time — instead it repairs the commission line to the value recorded in the audit.

No. Single-use enforcement is two-layered: application logic checks the code status, and the database has a unique index on (subscriber_id, lower(email)). On concurrent requests, the database rejects the second insert and the service handles it idempotently.

Yes. Just add a new code to PLATFORM_FUNDED_ADJUSTMENT_CODES. The entire commission and payout layer recognizes it automatically.

About a week including tests. Most of the time wasn't spent on the discount mechanism itself, but on making sure idempotency, auditing, and retry edge cases all work correctly.

The mechanics are independent of the Artovnia domain. If you're building a marketplace on Medusa and need platform-funded discounts — the foundation is ready to adapt.

Absolutely. Medusa doesn't impose rigid tax logic — rate configuration, gross/net handling, commission recalculations — everything can be adapted to Polish requirements. That's exactly what this implementation does, among other things, when adjusting the commission line.