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:
prowizja = 95 × 20% = 19 PLN
payout sprzedawcy = 95 - 19 = 76 PLNBez 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:
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:
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:
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%:
odjęcie 30 PLN od prowizji netto
= realna utrata 36,90 PLN prowizji bruttoDlatego finalizer musi rozróżniać oba przypadki:
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:
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.59Audyt 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.
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:
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:
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.
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:
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
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 PLNBez loyalty discount sprzedawca dostałby:
400.00 - 49.20 + 25.00 = 375.80 PLNIdentycznie. 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.


