Ceny walutowe

Ustawienia i konfiguracja

Wszystkie opcje wtyczki i miejsca, w których da się je nadpisać, dlaczego marża nie ma wartości domyślnej, dwie zmienne środowiskowe, strona w panelu i dwie trasy API.

Ustawienie może pochodzić z trzech miejsc, a rozstrzygają się one w ustalonej kolejności. Gdy masz tę kolejność w głowie, reszta tej strony z niej wynika.

  FX_PRICING_DISABLED (środowisko)     ->  potrafi tylko wymusić wyłączenie
            |
  Ustawienia > Ceny walutowe (baza)    ->  to, co zapisał operator
            |
  medusa-config.ts (opcje wtyczki)     ->  wartość zapasowa dla pustego nadpisania

Każda ścieżka wykonania - subskrybent zmiany ceny, codzienne zadanie, przycisk Przelicz teraz, trasa konfiguracyjna panelu - rozwiązuje to wszystko jedną metodą, getResolvedRuntimeOptions(), na początku każdego przebiegu. Nic nie jest zapamiętywane przy starcie procesu. Zmiana zapisana w panelu działa już przy najbliższym przebiegu, bez restartu.

Opcje wtyczki

Wszystkie trzy są nieobowiązkowe. Instalacja, która nie poda żadnej, konfiguruje się w całości z panelu.

OpcjaTypDomyślnieDo czego służy
enabledbooleanfalseZasiewa przełącznik w bazie, jednorazowo, przy tworzeniu wiersza ustawień
marginMultipliernumberbrakMarża zapasowa, gdy nie zapisano nadpisania
stalenessToleranceHoursnumber120Zapasowa tolerancja nieaktualności, w godzinach
sourcePriceIncludesVatbooleantrueCzy domyślna cena PLN jest brutto i musi zostać sprowadzona do netto przed przeliczeniem
vatRatenumber0.23Stawka VAT do odjęcia, gdy sourcePriceIncludesVat jest true; w przeciwnym razie ignorowana
medusa-config.ts
plugins: [
  {
    resolve: "@zanreal/medusa-fx-pricing",
    options: {
      enabled: false,
      marginMultiplier: 1.25,
      stalenessToleranceHours: 120,
      sourcePriceIncludesVat: true,
      vatRate: 0.23,
    },
  },
],

W odróżnieniu od marginMultiplier i stalenessToleranceHours, tych dwóch nie da się nadpisać z panelu - zobacz "VAT: brutto PLN, netto EUR/USD" poniżej, dlaczego.

Marża celowo nie ma wartości domyślnej

marginMultiplier to jedyna liczba w tej wtyczce, która decyduje o tym, ile zapłaci klient. Wartość domyślna byłaby preferencją handlową jakiegoś innego sklepu, nałożoną na Twoje ceny bez Twojego wyboru.

Dlatego jej nie ma. Dopóki marża nie jest ustawiona w medusa-config.ts albo w panelu, przeliczenie odmawia w całości, zanim pobierze choćby jeden kurs, zapisuje powód w podsumowaniu i nie zapisuje żadnej ceny. Strona ustawień pokazuje ostrzeżenie mówiące dokładnie to samo.

Wpisz 1, jeśli naprawdę chcesz goły kurs średni NBP bez narzutu. To też jest decyzja i o to chodzi, żeby została zapisana jako decyzja.

Tolerancja nieaktualności wartość domyślną ma

stalenessToleranceHours zachowuje swoje 120 i ta asymetria jest zamierzona: to tolerancja dla harmonogramu publikacji publicznej tabeli kursów, a nie preferencja handlowa. Niebezpieczny kierunek jest tu zresztą z góry zamknięty, bo ustawienie zbyt niskiej wartości powoduje najwyżej pominięcie przebiegu, a nigdy wyceny po kursie, którego nie chciałeś. Zobacz Kursy i dni bez kursu.

VAT: brutto PLN, netto EUR/USD

Sklep, dla którego powstała ta wtyczka, ma domyślne ceny skonfigurowane jako PLN brutto (23% VAT) i EUR/USD netto - price_preference.is_tax_inclusive to true dla pln i false zarówno dla eur, jak i usd. Wstawienie kwoty PLN wprost do pola, które sklep sam deklaruje jako netto, jest błędem niezależnie od marży: wkłada kwotę brutto tam, gdzie oczekiwane jest netto, więc 23% VAT jedzie dalej nieskorygowane i zawyża efektywny narzut - skonfigurowane 1.1 dało na produkcji efektywnie ~1,353, zanim to wyłapano (zobacz AI-655).

Kontrolują to sourcePriceIncludesVat (domyślnie true) i vatRate (domyślnie 0.23):

net_pln_amount = sourcePriceIncludesVat ? pln_amount / (1 + vatRate) : pln_amount
foreign_amount = net_pln_amount / nbp_rate * margin_multiplier

Jeśli domyślna cena PLN w Twoim sklepie jest netto, a nie brutto, ustaw sourcePriceIncludesVat: false w medusa-config.ts - vatRate jest wtedy całkowicie ignorowane, a surowa kwota PLN jest przeliczana dokładnie tak, jak przed pojawieniem się tej opcji. To jednolinijkowa, w pełni odwracalna zmiana, która zaczyna działać po najbliższym restarcie backendu i nie wymaga migracji ani zmiany w bazie danych. Dokładną matematykę i przypadki brzegowe zobacz w computeForeignAmount i toNetPlnAmount w src/modules/fx-pricing/lib/compute.ts oraz w ich testach.

Ustawienia w bazie

fx_pricing_settings to jednowierszowy singleton o stałym kluczu głównym. Powstaje przy pierwszym odczycie, a gdy dwa równoległe pierwsze odczyty się wyścigną, przegrany dokłada odczyt wiersza zwycięzcy, zamiast go duplikować.

Trzy ustawienia na tym wierszu nie są przechowywane tak samo i ta różnica jest zamierzona.

enabled to prawdziwa wartość logiczna, nie nadpisanie z pustką. Zostaje zasiane raz, z options.enabled, w chwili powstania wiersza, a potem zmienia je wyłącznie przełącznik w panelu. Dla wyłącznika bezpieczeństwa nie ma sensownego „wracaj do wartości z konfiguracji przy każdym odczycie”: operator go przestawia i to jest odpowiedź, dopóki nie przestawi go z powrotem. Późniejsza zmiana options.enabled w medusa-config.ts nie zrobi nic na instalacji, która ma już wiersz ustawień.

margin_multiplier i staleness_tolerance_hours to nadpisania z pustką. null znaczy „tu nie nadpisano”, a wtyczka przy każdym odczycie sięga wtedy po odpowiadającą opcję wtyczki. Wyczyszczenie nadpisania w panelu zapisuje null, czyli przywraca to ustawienie do tego, co mówi medusa-config.ts. Gdy margin_multiplier jest null i nie skonfigurowano opcji marginMultiplier, marży nie ma w ogóle i przebieg odmawia, zamiast ją wymyślać.

Na tym samym wierszu siedzą jeszcze last_run_at i last_run_summary, które nie są konfiguracją, tylko raportem z ostatniego przebiegu. Zapisywane po każdym przebiegu, udanym czy nie, i odczytywane przez stronę ustawień.

Zmienne środowiskowe

FX_PRICING_DISABLED

Wymusza wyłączenie wtyczki niezależnie od tego, co zapisano w bazie. Liczy się każda niepusta wartość poza 0 i false (porównanie bez rozróżniania wielkości liter, po obcięciu białych znaków).

Potrafi wyłącznie wymusić wyłączenie, nigdy włączenie. Operator może przestawić przełącznik w bazie także wtedy, gdy zmienna jest ustawiona, a ten zapisany stan zadziała w chwili jej usunięcia. Strona w panelu pokazuje w tym czasie plakietkę, żeby nikt nie zachodził w głowę, czemu przełącznik w pozycji „włączone” niczego nie robi.

Zastosowanie: środowisko, w którym zadanie i ręczna akcja nie mają prawa ruszyć niezależnie od zawartości bazy, na przykład staging albo wdrożenie, które właśnie badasz.

FX_PRICING_CRON

Nadpisuje harmonogram zadania. Domyślnie 0 3 * * *, czyli raz na dobę o 3:00. To długo po zamknięciu okna publikacji tabeli A (11:15 czasu środkowoeuropejskiego) za poprzedni dzień i sporo przed godzinami handlu większości sklepów, więc zmiana ceny nigdy nie zdarza się w trakcie czyjejś sesji zakupowej.

Zadanie jest zabezpieczeniem, a nie głównym mechanizmem - zmianę ceny w PLN łapie subskrybent w ciągu kilku sekund, a harmonogram rządzi wyłącznie nocnym przebiegiem po całym katalogu, który wyłapuje ruch kursu, cenę zapisaną poza workflow albo zdarzenie, które przepadło. Przesunięcie godziny to decyzja o tym, kiedy odbywa się ten przegląd, a nie o tym, jak szybko nowy produkt dostaje ceny.

Akurat ta rzecz jest zmienną środowiskową z powodu konstrukcyjnego, nie stylistycznego. Medusa oblicza config.schedule zadania w momencie ładowania wtyczki, czyli zanim powstanie kontener zależności, a więc i zanim istnieją rozwiązane opcje tej wtyczki. W chwili, w której harmonogram jest potrzebny, nie ma skąd czytać opcji.

Strona w panelu

Ustawienia > Ceny walutowe to jedyna powierzchnia tej wtyczki w panelu. Widżetu przy produkcie celowo nie ma: wtyczka nie ma nic do pokazania przy konkretnym produkcie, czego nie pokazuje już edytor cen wariantu.

  • Włączone - przełącznik zapisywany od razu po przestawieniu. Bez osobnego przycisku zapisu, bo to wyłącznik bezpieczeństwa, a nie pole formularza. Przy ustawionej zmiennej środowiskowej pokazuje plakietkę o wymuszonym wyłączeniu.
  • Konfiguracja - mnożnik marży i tolerancja nieaktualności, z przyciskiem zapisu i akcją Wyczyść zapisane wartości, która pojawia się po nadpisaniu którejkolwiek z nich. Dopóki marży nie ma nigdzie, ostrzeżenie tłumaczy, że przeliczenie nic nie zapisze, i dlaczego tak ma być.
  • Aktualne kursy NBP - pobierane na żywo przy każdym wejściu, żebyś mógł sprawdzić, co policzyłby najbliższy przebieg, zanim go uruchomisz.
  • Ostatnie uruchomienie - znacznik czasu i podsumowanie ostatniego przebiegu z podziałem na waluty, plus Przelicz teraz, które uruchamia tę samą logikę co zadanie i pokazuje wynik na miejscu.

API panelu

Obie trasy leżą pod /admin/fx-pricing i korzystają ze standardowego uwierzytelniania panelu Medusy.

GET /admin/fx-pricing/config

Zwraca rozwiązaną konfigurację, kursy na żywo i podsumowanie ostatniego przebiegu.

{
  "effectiveEnabled": true,
  "forceDisabled": false,
  "persistedEnabled": true,
  "marginMultiplier": 1.25,
  "marginMultiplierOverridden": false,
  "stalenessToleranceHours": 120,
  "stalenessToleranceHoursOverridden": false,
  "lastRunAt": "2026-08-13T03:00:00.000Z",
  "lastRunSummary": {
    "ranAt": "2026-08-13T03:00:00.000Z",
    "ran": true,
    "currencies": {
      "usd": {
        "reached": true,
        "currencyDisabled": false,
        "rateUnavailable": false,
        "rateStale": false,
        "failed": false,
        "plannedCreates": 3,
        "plannedUpdates": 12,
        "created": 3,
        "updated": 12,
        "unchanged": 140,
        "skippedManualOverride": 5,
        "skippedNoPlnPrice": 0,
        "skippedQuantityTiered": 0,
        "stampFailed": 0,
        "rate": 3.9123,
        "rateEffectiveDate": "2026-08-12"
      }
    },
    "pricesWritten": 15
  },
  "liveRates": {
    "usd": { "mid": 3.9123, "effectiveDate": "2026-08-13", "tableNo": "154/A/NBP/2026" },
    "eur": { "error": "NBP request for eur failed with status 503" }
  }
}

Trzy pola warto wskazać palcem.

persistedEnabled różni się od effectiveEnabled dokładnie wtedy, gdy forceDisabled jest prawdą. To właśnie pozwala stronie pokazać przełącznik w pozycji „włączone” razem z plakietką tłumaczącą, że środowisko go przykrywa.

Wpis w liveRates jest albo kursem, albo obiektem { "error": ... }, osobno i niezależnie dla każdej waluty. Błąd jednej nigdy nie wywraca całego żądania.

lastRunSummary jest null do zakończenia pierwszego przebiegu. W jego wnętrzu każda docelowa waluta jest obecna zawsze. Waluta, do której przebieg nie zdążył dojść, ma reached: false, a taka, której własny etap się wywrócił, ma failed: true i error - żadna nie znika z podsumowania. Wcześniejsza wersja po prostu pomijała walutę, do której nie doszła, co wyglądało dokładnie tak samo jak waluta przeliczona bez problemów.

POST /admin/fx-pricing/config

Zapisuje nadpisanie. Treść: { enabled?, margin_multiplier?, staleness_tolerance_hours? }. Zapisywane są wyłącznie klucze obecne w żądaniu. Odpowiedź ma ten sam kształt co GET i pokazuje stan tuż po zapisie.

  • enabled musi być wartością logiczną. Nie przyjmuje null, bo to prawdziwy przełącznik, a nie nadpisanie wartości zapasowej.
  • margin_multiplier musi być liczbą skończoną większą od 0 i nie większą niż 10, albo null w celu wyczyszczenia nadpisania. Sufit chroni przed pomyłką przy wpisywaniu, nie jest limitem biznesowym.
  • staleness_tolerance_hours musi być liczbą całkowitą większą od 0 i nie większą niż 720, czyli 30 dni, albo null w celu wyczyszczenia nadpisania.

Nieznany klucz, wartość złego typu albo treść bez ani jednego zapisywalnego klucza kończą się kodem 400 z opisem problemu.

POST /admin/fx-pricing/recompute

Uruchamia natychmiast to samo przeliczenie co zadanie i zwraca { "summary": { ... } } w kształcie RunSummary pokazanym wyżej.

Sama trasa nie sprawdza przełącznika. Sprawdzenie należy do wspólnej funkcji przeliczającej, dzięki czemu trasa i zadanie nie mogą się rozjechać w ocenie, czy wolno im działać: przy wyłączonej wtyczce obie dostają { "ran": false } i nic nie zostaje zapisane.

Jak czytać podsumowanie przebiegu

PoleZnaczenie
ranAtZnacznik czasu przebiegu w formacie ISO
ranfalse, gdy przebieg nic nie zrobił, bo przełącznik był wyłączony
triggerCo uruchomiło przebieg: scheduled, manual, event albo workflow
scopedVariantCountDo ilu wariantów przebieg został zawężony, albo null przy przejściu całego katalogu
error / errorName / errorStackObecne, gdy przebieg się nie powiódł. Komunikat jest prawdziwy, nigdy "[object Object]"
pricesWrittenCeny zapisane i oznaczone jako własne, łącznie we wszystkich walutach. 0 przy zakończonym przebiegu jest wprost pokazywane w panelu
currencies[kod].reachedfalse, gdy przebieg skończył się, zanim doszedł do tej waluty
currencies[kod].failed / errorEtap tej waluty się wywrócił. Kolejna waluta i tak została policzona
currencies[kod].currencyDisabledWaluty nie ma wśród obsługiwanych przez sklep
currencies[kod].rateUnavailableKursu NBP nie udało się pobrać ani sparsować
currencies[kod].rateStaleNajnowszy opublikowany kurs jest starszy niż tolerancja
currencies[kod].plannedCreates / plannedUpdatesCo przebieg postanowił zrobić, zanim cokolwiek zapisał
currencies[kod].created / updatedCo naprawdę zostało zapisane i oznaczone jako własne
currencies[kod].unchangedJuż równe celowi i wciąż prowadzone przez wtyczkę
currencies[kod].skippedManualOverrideZostawione w spokoju, zobacz Ręczne nadpisania
currencies[kod].skippedNoPlnPriceBrak używalnej domyślnej ceny w PLN, z której dałoby się przeliczyć
currencies[kod].skippedQuantityTieredWariant wyceniony progami ilościowymi, zobacz Ręczne nadpisania
currencies[kod].stampFailedZapisane, ale nieodnotowane jako własne. Nigdy nie jest to normalne, zobacz niżej
currencies[kod].rate / rateEffectiveDateKurs użyty w przebiegu, obecny zawsze, gdy udało się go pobrać

created czytaj razem z plannedCreates, nie osobno. To dwa pola, bo kiedyś było to jedno: przebieg raportował created: 61, choć nie zapisał ani jednej ceny

  • licznik brał się z planu, a zapisy, które po nim szły, się wywróciły. plannedCreates to zamiar, created to wynik, a różnica między nimi znaczy, że zapis albo oznaczenie nie doszło do skutku.

stampFailed nigdy nie jest normalne. Cena, którą wtyczka zapisała, ale której nie zdołała oznaczyć jako swojej, od tego momentu - zgodnie z jej własną regułą własności - należy do kogoś innego: kolejny przebieg widzi cenę, o której nie ma śladu, że ją pisał, i pomija ją na stałe. Lekarstwem jest usunięcie takich cen, żeby następny przebieg utworzył je i oznaczył od nowa.

Uruchamianie workflow bezpośrednio

Wtyczka eksportuje swoje workflow, żeby projekt gospodarza mógł wywołać przeliczenie z własnego skryptu, akcji w panelu albo harmonogramu:

import { recomputeFxPricesWorkflow, runFxPricingRecompute } from "@zanreal/medusa-fx-pricing/workflows";

runFxPricingRecompute(container) to zwykła funkcja asynchroniczna, którą wywołuje i zadanie, i trasa panelu, i subskrybent. recomputeFxPricesWorkflow opakowuje ją jako workflow do złożenia w większą całość.

Drugi argument zawęża i opisuje przebieg:

await runFxPricingRecompute(container, {
  trigger: "event",              // "scheduled" | "manual" | "event" | "workflow"
  variantIds: ["variant_01ABC"], // pomiń, żeby przejść cały katalog
});

variantIds zmienia wyłącznie to, które warianty są czytane - każda reguła (przełącznik, odmowa przy braku marży, pomijanie walut, rozstrzygnięcie o ręcznym nadpisaniu, stemplowanie) to ten sam kod w obu przypadkach. Pusta tablica oznacza zero wariantów, a nie wszystkie: wywołujący, który zredukował swoje wejście do niczego, nie może wpaść w przecenę całego sklepu. Podsumowanie jako last_run_summary zapisuje tylko pełny przebieg; zawężony melduje się w logu.

Kompensacji celowo nie ma. Każdy zapis to uzgodnienie cen wyprowadzonych z PLN w stronę celu policzonego na świeżo z bieżącej ceny w PLN i bieżącego kursu, więc naprawą po przerwanym przebiegu jest po prostu kolejny przebieg. Kompensacja cofająca połowiczne przeliczenie zostawiłaby ceny dalej od celu niż przed startem, a nie bliżej.

Suchy przebieg (dry run)

previewFxPricingRecompute to bliźniak runFxPricingRecompute działający wyłącznie do odczytu: wykonuje to samo odczytanie katalogu, to samo pobranie żywego kursu NBP i to samo planCurrencyRecompute, co prawdziwy przebieg, ale nigdy nie zapisuje ceny i nigdy nie zapisuje podsumowania przebiegu ani stempla zarządzanej ceny. Odpowiada na pytanie "co by się zmieniło" - bieżąca cena PLN, baza netto, z której faktycznie policzono by przeliczenie (zobacz "VAT: brutto PLN, netto EUR/USD" wyżej), i wynikowa kwota EUR/USD - zanim cokolwiek zostanie uzbrojone albo uruchomione naprawdę.

Wtyczka Medusa nie może sama nosić skryptu medusa exec - taki skrypt należy do src/scripts/ projektu gospodarza. Dodaj tam jeden:

src/scripts/fx-pricing-preview.ts (projekt gospodarza)
import type { MedusaContainer } from "@medusajs/framework/types";
import { formatFxPricingPreview, previewFxPricingRecompute } from "@zanreal/medusa-fx-pricing/workflows";

export default async function fxPricingPreview({ container }: { container: MedusaContainer }) {
  console.log(formatFxPricingPreview(await previewFxPricingRecompute(container)));
}
npx medusa exec ./src/scripts/fx-pricing-preview.js

Nic to nie zapisuje i nie przełącza żadnego ustawienia. Raport wymienia, dla każdej waluty: żywy kurs i to, czy jest nieaktualny, po jednej linii na wariant, który ten przebieg by utworzył albo zaktualizował (bieżące PLN, użytą bazę netto i proponowaną kwotę), oraz liczniki unchanged/manualOverride/noPlnPrice/ quantityTiered dla wszystkiego innego. Gospodarz, który chce innego formatu wyjścia, może wywołać previewFxPricingRecompute bezpośrednio i samodzielnie wyrenderować surowy FxPricingPreviewResult, zamiast formatFxPricingPreview.

Spis treści