Abstrakt image hero
Strona główna/Blog/Platform-funded discounts w Medusa.js — loyalty points i newsletter signup bez obciążania sprzedawcy

Platform-funded discounts w Medusa.js — loyalty points i newsletter signup bez obciążania sprzedawcy

Arkadiusz Wawrzyniak

Arkadiusz Wawrzyniak

10 czerwca 2026 - 85 wyświetlenia - 5 min czytania

Udostępnij

W zwykłym sklepie implementacja rabatu jest prosta: klient płaci mniej, koniec historii. W Medusa.js zwykłe promocje są natywnym modułem, ale
w przypadku marketplace sprawy szybko się komplikują i potrzebne są customowe rozwiązania bo pojawia się sprzedawca — i jedno pytanie, które trzeba zadać zanim napiszesz pierwszą linię kodu: kto finansuje ten rabat?

To pytanie wymusiło na mnie niestandardową architekturę przy budowie nowych funkcji w Artovnia.com.
W tym poście opisuję, jak zaimplementowałem dwa rabaty finansowane przez platformę — loyalty points i newsletter signup — bez obciążania sprzedawców promocją platformy.

Finanse i problem, który łatwo przeoczyć

Wyobraź sobie prosty scenariusz:

  • produkt kosztuje 100 PLN,
  • prowizja platformy wynosi 20%,
  • platforma oferuje nowym subskrybentom newslettera rabat 5%.

Prymitywna implementacja: dodaję line item adjustment z kodem NEWSLETTER_SIGNUP, item.total spada do 95 PLN. Zrobione.

Ale teraz policzmy payout sprzedawcy:

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

Bez newsletter discount sprzedawca dostałby 100 - 20 = 80 PLN.

Platforma oferuje rabat promujący jej własny newsletter, a sprzedawca traci 4 PLN — bez żadnego powiadomienia, bez zgody, bez sensu. To nie jest bug biblioteki. To jest konsekwencja tego, że standardowy mechanizm rabatów w każdym systemie koszykowym — włącznie z Medusą — działa na item.total, nie na commission.

Architektura dwuetapowa

Żeby sprzedawca nie finansował rabatu platformy, każdy platform-funded discount musi przejść przez dwa kroki:

Krok 1 — przy liczeniu prowizji: dodaj rabat z powrotem do bazy prowizji.

Krok 2 — po naliczeniu prowizji: odejmij rabat od commission line platformy.

Efekt końcowy przy newsletter discount 5 PLN:

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 ✓

Sprzedawca dostaje tyle samo co bez rabatu. Klient płaci mniej. Platforma finansuje różnicę ze swojej prowizji. Wilk syty i owca cała.

Jedna definicja, dwa mechanizmy

Zaimplementowałem dwa typy platform-funded discounts: LOYALTY_POINTS i NEWSLETTER_SIGNUP. Żeby nie rozrzucać logiki po całym kodzie — checkout, commission, payout — zdefiniowałem je raz w jednym miejscu:

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 może normalnie obniżać item.total. Natomiast każde miejsce, które liczy prowizję lub payout, wie, które kody są finansowane przez platformę — i traktuje je inaczej.

Add-back w calculate-commission-lines

W calculate-commission-lines.ts przed wyliczeniem prowizji dla każdego itemu dodaję z powrotem platform-funded adjustments:

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
)

Zwykłe rabaty sprzedawcy nadal obniżają bazę prowizji — bo wpływają na cenę produktu. Tylko platform-funded adjustments są neutralizowane na tym etapie.

Korekta prowizji z uwzględnieniem VAT

Tu jest element logiki, który łatwo zepsuć ale jego znaczenie jest kluczowe.

Rabat klienta to kwota brutto — klient zapłacił 5 PLN mniej. Natomiast commission_line.value może być kwotą netto (gdy include_tax = false). Gdybym odjął 5 PLN brutto od prowizji netto, platforma oddałaby więcej niż powinna.

Przykład przy VAT 23%:

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

Dlatego finalizer musi rozróżniać oba przypadki:

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,
  }
}

Realny przykład liczbowy z manualnego testu:

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

Audyt przed mutacją

Finalizer najpierw zapisuje audit w platform_commission_adjustment, dopiero potem aktualizuje commission line. Kolejność nie jest przypadkowa — jeśli update się wywróci, masz ślad. Jeśli audit się wywróci, nie masz mutacji bez śladu.

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,
})

Każda korekta prowizji ma czytelny ślad: skąd pochodzi rabat, jaka była prowizja przed i po, z jakim VAT-em.

Idempotencja i retry

Order lifecycle w Medusie to środowisko, gdzie hooki mogą być uruchomione ponownie. Każda korekta ma deterministyczny klucz:

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

Przy ponownym uruchomieniu finalizera nie odejmuję prowizji drugi raz. Zamiast tego naprawiam commission line do wartości zapisanej w audycie:

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

Retry i replay stają się bezpieczne. To szczególnie ważne, bo create-commission-lines może odtworzyć commission lines po stronie orderu — finalizer musi to przeżyć bez podwójnej korekty.

Loyalty — punkty zostają w swojej domenie

Oba mechanizmy korzystają z tego samego finalizera prowizji, ale loyalty ma własną logikę punktową całkowicie odizolowaną od commission. Każda warstwa robi swoje — i tylko swoje.

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}`,
})

Podział odpowiedzialności: finalizer prowizji wie, jak korygować commission line. Moduł loyalty wie, jak zarządzać punktami i saldem konta. Łączy je order ID i audit w platform_commission_adjustment.

Safety cap

Nie każda kwota punktów powinna być możliwa do realizacji. Rabat platform-funded nie może przekroczyć prowizji platformy — w przeciwnym razie platforma dopłacałaby ponad swoją marżę albo payout sprzedawcy przestałby być stabilny.

Przy aplikowaniu rabatu do koszyka estymowana jest maksymalna prowizja z danego zamówienia. To jest górny limit dla sumy platform-funded adjustments. Jeśli klient chce użyć więcej punktów niż pozwala safety cap — system przycina rabat, nie błęduje. Cicho, deterministycznie, bez wyjątku.

Newsletter: jednorazowość z gwarancją bazy danych

Kod powitalny newslettera jest jednorazowym uprawnieniem per subscriber/email. Poza logiką aplikacyjną gwarancja jednorazowości jest wymuszona na poziomie bazy — bo logika aplikacyjna zawodzi przy 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;

Przy równoległych requestach (dwa kliknięcia, race condition) baza odrzuca drugi insert. Serwis obsługuje konflikt idempotentnie — doczytuje istniejący rekord i traktuje request jako powtórzenie.

Pełny przykład końcowy

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

Bez loyalty discount sprzedawca dostałby:

Bash
400.00 - 49.20 + 25.00 = 375.80 PLN

Identycznie. Tak ma wyglądać dobra abstrakcja.

Podsumowanie

Standardowy rabat w Medusie (i w praktycznie każdym innym frameworku e-commerce) redukuje item.total. W marketplace to za mało — ktoś musi zapłacić za różnicę i domyślnie tym kimś jest sprzedawca.

Rozwiązanie jest dwuetapowe: add-back do commission base, korekta commission line po naliczeniu prowizji. Do tego:

  • jedna definicja platform-funded codes jako źródło prawdy dla checkout, commission i payout,
  • przeliczenie rabatu brutto przez VAT przy korygowaniu netto commission line,
  • audit przed mutacją, idempotency key chroniący przed podwójną korektą przy retry,
  • safety cap pilnujący, żeby rabat nie przekroczył prowizji platformy,
  • unikalna gwarancja bazy dla jednorazowych uprawnień.

To jest implementacja, której nie robi się przez zainstalowanie pluginu.

Buduję customowe rozwiązania na Medusa.js — od architektury modułów przez integracje po całe marketplace.

FAQ

Najczęściej zadawane pytania

Pełny zwrot w oknie zwrotów jest czysty — payout nie został wypłacony, więc nie ma czego odwracać. Przy częściowym zwrocie system wyklucza commission lines zwróconych itemów z wypłaty zamiast je odtwarzać. Rebuild commission lines obsługuje finalizer: jeśli linia została odbudowana po retry, audit przepina się na nową i ustawia poprawną wartość.

Każda korekta prowizji ma deterministyczny idempotency key. Przy powtórnym wywołaniu finalizer nie odejmuje rabatu drugi raz — zamiast tego naprawia commission line do wartości z audytu.

Nie. Jednorazowość jest pilnowana dwupoziomowo: logika aplikacyjna sprawdza status kodu, a baza danych ma unique index na (subscriber_id, lower(email)). Przy równoległych requestach baza odrzuca drugi insert, serwis obsługuje to idempotentnie.

Tak. Wystarczy dodać nowy kod do PLATFORM_FUNDED_ADJUSTMENT_CODES. Cała warstwa commission i payout rozpoznaje go automatycznie.

Około tygodnia razem z testami. Większość czasu nie zajął sam mechanizm rabatu, tylko upewnienie się że idempotencja, audit i edge case'y przy retry działają poprawnie.

Mechanika jest niezależna od domeny Artovni. Jeśli budujesz marketplace na Medusie i masz potrzebę finansowania rabatów po stronie platformy — fundament jest gotowy do adaptacji.

Jak najbardziej. Medusa nie narzuca sztywnej logiki podatkowej — konfiguracja stawek, obsługa brutto/netto, przeliczenia przy prowizji — wszystko można dostosować do polskich realiów. To właśnie robi ta implementacja między innymi przy korekcie commission line.