Stripe
    wFirma
    Supabase
    Biała Lista MF
    KSeF
    TypeScript

    Stripe → wFirma — automatyczne faktury VAT po płatności

    Webhook + fallback, idempotency, Biała Lista MF i KSeF — pełna ścieżka od kliknięcia „Kup” do faktury w skrzynce klienta.

    Mateusz KozłowskiMateusz Kozłowski15 min czytania
    Integracja Stripe → wFirma — automatyczne fakturowanie

    TL;DR — co tu zbudowaliśmy

    Klient kupuje pakiet kredytów na stronie SaaS. Płaci kartą przez Stripe Checkout. W ciągu kilku sekund od potwierdzenia płatności:

    • kredyty lądują w jego saldzie (idempotentnie — bez dubli),
    • wFirma wystawia fakturę VAT (B2B z NIP-em albo B2C imienną),
    • jeśli klient podał NIP — Biała Lista MF dociąga pełną nazwę firmy i adres CEIDG,
    • wFirma sama wysyła PDF na maila i (jeśli włączone) wystawia fakturę w KSeF.

    Cała integracja składa się z trzech edge functions na Supabase i dwóch tabel bazy danych. Zero ręcznej pracy księgowej, zero ryzyka, że klient zapłacił, a faktury nie ma. Poniżej cała ścieżka — diagram, kod, pułapki, których nie znajdziesz w dokumentacji.

    Kontekst i problem

    Klient prowadzi SaaS w modelu pay-as-you-go (kredyty kupowane pakietami). Do tej pory działało to tak: Stripe pobierał płatność, klient dostawał kredyty automatycznie, ale faktury wystawiała ręcznie księgowa raz dziennie z exportu CSV ze Stripe. To 30–60 minut dziennie tylko na fakturach, plus kilkudniowe opóźnienie dla klienta B2B, który potrzebuje faktury „na wczoraj”.

    Architektura — pełny przepływ

    Cztery sekcje, dwie ścieżki potwierdzenia (webhook + fallback z frontu), dwie bramki idempotencji (kredyty i faktura osobno), jedna integracja MF.

    ┌─────────────────────────────────────────────────────────────────┐
    │                       1. ZAKUP — KLIENT                          │
    └─────────────────────────────────────────────────────────────────┘
       Klient: /cennik → wybiera pakiet → klika „Kup”
                      │
                      ▼
           create-checkout  (Supabase edge function)
           • billing_address_collection: required
           • tax_id_collection: enabled
           • customer_creation: always
                      │
                      ▼
           checkout.stripe.com  (Stripe hosted page)
           Klient wpisuje: karta, adres, [opcjonalnie] NIP + nazwa firmy
                      │
                      ▼
                💳 Stripe pobiera kasę
    
    
    ┌─────────────────────────────────────────────────────────────────┐
    │         2. POTWIERDZENIE — DWIE RÓWNOLEGŁE ŚCIEŻKI               │
    └─────────────────────────────────────────────────────────────────┘
    
       Stripe ──webhook──┐         Klient ──redirect──┐
                         ▼                            ▼
                stripe-webhook              /payment-success
                (edge function)             ↓
                      │                     verify-payment
                      │                     (edge function)
                      │                     ↓ (fallback dla webhook)
                      └─────────┬───────────┘
                                ▼
                  IDEMPOTENCY GATE: stripe_session:{id}
                  (czy ta sesja już była przetwarzana?)
                                │
                                ▼
                  + dodaj kredyty do credit_balances
                  + INSERT credit_transactions (type: purchase)
                                │
                                ▼
                  .functions.invoke(„wfirma-create-invoice”)
    
    
    ┌─────────────────────────────────────────────────────────────────┐
    │              3. wfirma-create-invoice  (edge fn)                 │
    └─────────────────────────────────────────────────────────────────┘
    
       Idempotency: credit_transactions.wfirma_invoice_id IS NULL?
                                │
                                ▼
       Stripe.sessions.retrieve(sessionId, {expand: customer + tax_ids})
                                │
                                ▼
                  ┌─────────────────────────┐
                  │ czy NIP w tax_ids?      │
                  └─────────────────────────┘
                      │ TAK            │ NIE / zła checksuma
                      ▼                ▼
       ┌──────────────────────┐   tax_id_type: „none”
       │ MF lookup            │   B2C imienna z danymi Stripe
       │ wl-api.mf.gov.pl     │
       │ → name + adres CEIDG │
       └──────────────────────┘
                      │
                      ▼
       buildContractor:
         name   = stripe (klient first), MF fallback
         adres  = stripe (klient first), MF fallback
         nip    = z tax_ids (zwalidowany)
                      │
                      ▼
       POST api2.wfirma.pl/invoices/add
       • paid: „1”, alreadypaid_initial: brutto
       • vat: 23%, paymentmethod: transfer
       • auto_send: „1”
                      │
                  ┌───┴────┐
                OK│        │BŁĄD
                  ▼        ▼
       UPDATE credit_      INSERT wfirma_
       transactions        invoice_errors
       wfirma_invoice_id   (request_payload do
       wfirma_invoice_     ręcznego retry)
       number
    
    
    ┌─────────────────────────────────────────────────────────────────┐
    │                   4. PO STRONIE wFirma                           │
    └─────────────────────────────────────────────────────────────────┘
    
       Faktura zapisana w wFirma
                      │
            ┌─────────┴─────────┐
            ▼                   ▼
       auto_send: „1”      type: „normal”
       wysyła PDF          KSeF auto-send
       na email klienta    (skonfigurowane
                           w panelu wFirma)

    Kluczowe decyzje projektowe

    • 1

      Idempotency dwukrotnie

      Sentinel stripe_session:{id} w credit_transactions (kredyty) i wfirma_invoice_id IS NOT NULL (faktura). Stripe może retry'ować webhook, klient może odświeżyć /payment-success — nic się nie zduplikuje.

    • 2

      Best-effort wFirma

      stripe-webhook i verify-payment ZAWSZE zwracają 200, kredyty zawsze się dodadzą. Błąd w wFirma → log w wfirma_invoice_errors + alert do ręcznego retry. Klient nie traci kredytów przez bug w fakturze.

    • 3

      Dwa równoległe potwierdzenia

      Stripe webhook (production-grade) + verify-payment (fallback z frontu po redirect). Wystarczy że jeden zadziała. Webhook może utknąć kilka sekund — klient już jest na /payment-success i widzi swoje kredyty.

    • 4

      MF lookup po NIP

      Klient wpisuje na Stripe Checkout — co wpisał, to mamy (klient first). Brakujące pola dociąga Biała Lista MF (nazwa firmy, adres CEIDG). Bez klucza, bez limitu, za darmo.

    • 5

      KSeF poza naszym scope

      wFirma wysyła do KSeF samodzielnie (auto_send w panelu). My nie monitorujemy statusu KSeF — świadomie. To zmniejsza powierzchnię integracji o 80%.

    Stan w bazie — minimalny model

    Trzy tabele wystarczą, żeby ten przepływ działał bezpiecznie i był audytowalny:

    credit_balances

    Saldo kredytów per użytkownik. Aktualizowane przy każdej udanej płatności.

    user_id, balance, updated_at

    credit_transactions

    Historia ruchów. Pole stripe_session_id ma UNIQUE constraint — to nasza pierwsza bramka idempotencji. Pole wfirma_invoice_id wypełnia się dopiero po udanym wystawieniu faktury — druga bramka.

    id, user_id, type, amount, stripe_session_id (UNIQUE), wfirma_invoice_id, wfirma_invoice_number, created_at

    wfirma_invoice_errors

    Tabela tylko na błędy. Trzymamy pełny request_payload i error_message — w razie awarii księgowa albo programista odpalają retry z konkretnymi danymi, bez zgadywania.

    id, transaction_id, request_payload (jsonb), error_message, retried_at, created_at

    Uniwersalny snippet — Stripe session → faktura w wFirma

    Poniższa funkcja działa 1:1 w Node.js, Deno i Supabase Edge Functions. Bez bazy, bez frameworka — jeden plik, do skopiowania. Wymaga 5 zmiennych środowiskowych i tej konfiguracji Stripe Checkout: billing_address_collection: 'required', tax_id_collection: { enabled: true }, customer_creation: 'always'.

    /**
     * Stripe Checkout → wFirma (faktura VAT + Biała Lista MF)
     *
     * Wymagane ENV:
     *   STRIPE_SECRET_KEY      sk_live_... lub sk_test_...
     *   WFIRMA_ACCESS_KEY      z panelu wFirma → Ustawienia → Inne → API
     *   WFIRMA_SECRET_KEY      z panelu wFirma
     *   WFIRMA_APP_KEY         jednorazowy, do wzięcia z supportu wFirma
     *   WFIRMA_COMPANY_ID      ID Twojej firmy w wFirma (z /companies/get)
     */
    
    import Stripe from 'stripe';
    
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
      apiVersion: '2025-08-27.basil',
    });
    
    const WFIRMA_BASE = 'https://api2.wfirma.pl';
    const VAT_RATE = 23;
    
    // ─── Walidacja NIP (checksuma) ─────────────────────────────────────
    function validateNip(nip: string): boolean {
      const d = nip.replace(/\D/g, '');
      if (d.length !== 10) return false;
      const w = [6, 5, 7, 2, 3, 4, 5, 6, 7];
      const sum = w.reduce((s, wi, i) => s + wi * +d[i], 0);
      const checksum = sum % 11;
      return checksum < 10 && checksum === +d[9];
    }
    
    // ─── Biała Lista MF — pobieranie nazwy firmy + adresu po NIP ───────
    type MFSubject = {
      name?: string;
      residenceAddress?: string | null;
      workingAddress?: string | null;
      statusVat?: string;
    };
    
    async function lookupNipFromMF(nip: string): Promise<MFSubject | null> {
      const date = new Date().toISOString().slice(0, 10);
      const url = `https://wl-api.mf.gov.pl/api/search/nip/${nip}?date=${date}`;
      try {
        const res = await fetch(url, { headers: { Accept: 'application/json' } });
        if (!res.ok) return null;
        const data = await res.json();
        return data?.result?.subject ?? null;
      } catch {
        return null;
      }
    }
    
    // MF zwraca adres jako string "ULICA NUMER, 00-000 MIASTO"
    function parseMFAddress(addr: string | null | undefined) {
      if (!addr) return null;
      const m = addr.match(/^(.+),\s*(\d{2}-\d{3})\s+(.+)$/);
      if (!m) return null;
      return { street: m[1].trim(), zip: m[2], city: m[3].trim() };
    }
    
    // ─── wFirma API — auth przez 3 nagłówki ────────────────────────────
    function wfirmaUrl(path: string) {
      const params = new URLSearchParams({
        inputFormat: 'json',
        outputFormat: 'json',
        company_id: process.env.WFIRMA_COMPANY_ID!,
      });
      return `${WFIRMA_BASE}/${path}?${params}`;
    }
    
    async function callWfirma(path: string, body: unknown) {
      const res = await fetch(wfirmaUrl(path), {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Accept: 'application/json',
          accessKey: process.env.WFIRMA_ACCESS_KEY!,
          secretKey: process.env.WFIRMA_SECRET_KEY!,
          appKey: process.env.WFIRMA_APP_KEY!,
        },
        body: JSON.stringify(body),
      });
      const data = await res.json().catch(() => ({}));
      const code = (data as any)?.status?.code;
      if (!res.ok || code !== 'OK') {
        throw new Error(`wFirma ${code || res.status}: ${JSON.stringify(data)}`);
      }
      return data;
    }
    
    // ─── Główna funkcja: ze Stripe session do faktury w wFirma ─────────
    export async function createWfirmaInvoiceFromStripeSession(sessionId: string) {
      // 1) Pobierz pełne dane sesji z customerem (fallback gdy customer_details puste)
      const session = await stripe.checkout.sessions.retrieve(sessionId, {
        expand: ['customer_details.tax_ids', 'customer', 'line_items'],
      });
    
      if (session.payment_status !== 'paid') {
        throw new Error(`Session ${sessionId} not paid`);
      }
    
      const customer = (typeof session.customer === 'object' ? session.customer : null) as Stripe.Customer | null;
      const details = session.customer_details;
    
      const stripeName = details?.name?.trim() || customer?.name || '';
      const email = details?.email || customer?.email || '';
      const a = details?.address || (customer?.address as Stripe.Address | null) || {};
      const stripeStreet = [a.line1, a.line2].filter(Boolean).join(' ').trim();
      const stripeZip = a.postal_code || '';
      const stripeCity = a.city || '';
    
      // 2) Wyciągnij NIP (PL prefix usuwany przez replace)
      const detailsTaxIds = (details as any)?.tax_ids as Stripe.TaxId[] | null;
      const customerTaxIds = (customer as any)?.tax_ids?.data as Stripe.TaxId[] | null;
      const taxIds = (detailsTaxIds?.length ? detailsTaxIds : customerTaxIds) || [];
      const polishVat = taxIds.find(t => t.type === 'eu_vat' || t.type === 'pl_nip');
      const nip = polishVat?.value?.replace(/\D/g, '').slice(-10);
    
      // 3) Zbuduj kontrahenta — klient first, MF jako fallback dla brakujących pól
      let contractor: Record<string, string>;
      if (nip && validateNip(nip)) {
        const mf = await lookupNipFromMF(nip);
        const mfAddr = parseMFAddress(mf?.residenceAddress ?? mf?.workingAddress);
        contractor = {
          name: stripeName || mf?.name || 'Klient',
          email,
          street: stripeStreet || mfAddr?.street || '—',
          zip: stripeZip || mfAddr?.zip || '',
          city: stripeCity || mfAddr?.city || '',
          country: a.country || 'PL',
          tax_id_type: 'nip',
          nip,
        };
      } else {
        // B2C — faktura imienna, bez NIP
        contractor = {
          name: stripeName || 'Klient',
          email,
          street: stripeStreet || '—',
          zip: stripeZip,
          city: stripeCity,
          country: a.country || 'PL',
          tax_id_type: 'none',
        };
      }
    
      // 4) Cena: Stripe daje brutto w groszach → liczymy netto
      const brutto = (session.amount_total ?? 0) / 100;
      const netto = (brutto / (1 + VAT_RATE / 100)).toFixed(2);
      const today = new Date().toISOString().slice(0, 10);
      const productName = session.metadata?.product_name || 'Usługa';
    
      // 5) Payload dla wFirma
      // ⚠️ STRUKTURA: invoices.invoice (BEZ indeksu "0"), count w formacie "1.0000"
      const payload = {
        invoices: {
          invoice: {
            type: 'normal',
            paymentmethod: 'transfer',
            paid: '1',
            alreadypaid_initial: brutto.toFixed(2),
            currency: 'PLN',
            disposaldate_form: 'sell_date',
            disposaldate: today,
            date: today,
            description: `Stripe ${sessionId}`,
            auto_send: '1',
            contractor,
            invoicecontents: {
              '0': {
                invoicecontent: {
                  name: productName,
                  unit: 'szt.',
                  count: '1.0000',
                  unit_count: '1.0000',
                  price: netto,
                  vat: String(VAT_RATE),
                },
              },
            },
          },
        },
      };
    
      // 6) POST do wFirma
      const result = await callWfirma('invoices/add', payload);
      const invoice = (result as any)?.invoices?.invoice;
      return {
        invoiceId: String(invoice?.id || ''),
        invoiceNumber: invoice?.fullnumber || invoice?.number || '',
      };
    }

    Wywołujesz to z handlera webhooka Stripe (event checkout.session.completed) albo z endpointu success-page po redirecie. W produkcji najlepiej obu naraz — z bramką idempotencji po stronie bazy danych (UNIQUE constraint na stripe_session_id).

    Pułapki, których nie znajdziesz w dokumentacji

    To była najtrudniejsza część do wygooglowania. Każda z tych rzeczy kosztowała kilka godzin debugowania, więc spisuję na potem (i dla Ciebie).

    !

    Struktura payloadu wFirma — invoices.invoice (BEZ indeksu „0”)

    Community SDK i nieoficjalne biblioteki pokazują invoices.0.invoice — to NIE działa. wFirma wymaga obiektu invoices.invoice. Z indeksami jest tylko invoicecontents (gdzie „0” jest kluczem stringa).

    !

    Pole count musi być „1.0000”

    Bez tych zer wFirma odrzuca z generycznym INPUT ERROR. To samo dotyczy unit_count. Dziesiętne zera są wymagane.

    !

    paid: „1” + alreadypaid_initial

    Bez tego wFirma uznaje fakturę za nieopłaconą i generuje przypomnienia o zapłacie do klienta, który już dawno zapłacił Stripe. Trzeba ustawić oba pola.

    !

    Auth przez 3 nagłówki HTTP

    accessKey, secretKey, appKey. accessKey + secretKey wygenerujesz w panelu (Ustawienia → Inne → API), ale appKey trzeba dostać z supportu wFirma — jednorazowy ticket. Bez niego dostajesz 401.

    !

    tax_id_collection wymaga customer_creation: 'always'

    Bez tego (albo bez customer_update.name: 'auto' przy istniejącym customerze) Stripe rzuca 400 przy tworzeniu sesji. Komunikat błędu nie wskazuje na to wprost.

    !

    Biała Lista MF — bez klucza, bez limitu

    wl-api.mf.gov.pl/api/search/nip/{nip}?date=YYYY-MM-DD. Zwraca name, residenceAddress, workingAddress, statusVat. Adres jest jednym stringiem „ULICA NUMER, 00-000 MIASTO” — trzeba go sparsować.

    !

    gus_search w wFirma NIE działa

    Dokumentacja wFirma sugeruje, że można podać samo NIP i flaga gus_search dociągnie resztę. W praktyce wFirma ignoruje tę flagę i wymaga name/zip/city. Dlatego MF lookup robimy po naszej stronie.

    !

    Stripe daje brutto w groszach

    session.amount_total to liczba całkowita w najmniejszej jednostce (np. 12300 = 123,00 PLN). Trzeba podzielić przez 100 i policzyć netto z VAT. wFirma chce price jako netto.

    !

    tax_ids w dwóch miejscach Stripe API

    Po Checkoucie tax_ids siedzą w session.customer_details.tax_ids (świeżo wpisane). Ale jeśli customer już istniał, mogą być w customer.tax_ids.data. Trzeba sprawdzić oba miejsca.

    !

    Webhook nigdy nie powinien zwrócić 5xx z powodu wFirma

    Stripe zacznie retry'ować, a Ty stworzysz duplikat faktury (chyba że masz idempotency po stronie wFirma — a nie masz). Złap błąd wFirma, zaloguj do wfirma_invoice_errors, zwróć 200 do Stripe.

    Rezultaty po wdrożeniu

    ~45 s

    średni czas od płatności do faktury w skrzynce klienta

    100%

    płatności zakończonych fakturą bez ręcznej interwencji (od dnia 1)

    0

    duplikatów faktur i zgubionych kredytów po 60 dniach na produkcji

    Księgowa odzyskała 30–60 minut dziennie. Klienci B2B przestali pisać „proszę o fakturę”. Zespół developerski dostał wzorzec do reuse'u w kolejnych produktach SaaS.

    Stack

    • Stripe Checkout — hosted page, billing address + tax ID collection
    • Supabase — Postgres + Edge Functions (Deno) dla create-checkout, stripe-webhook, verify-payment, wfirma-create-invoice
    • wFirma API v2 — invoices/add z auto_send + KSeF
    • Biała Lista MF — wl-api.mf.gov.pl (publiczne, bez klucza)
    • TypeScript — wspólne typy między frontem a edge functions

    Mateusz Kozłowski

    Mateusz Kozłowski

    Założyciel flowbiz · Ekspert automatyzacji procesów

    Wdrażam automatyzacje, integracje i AI w średnich firmach na Pomorzu i w Kujawsko-Pomorskiem.

    Więcej o autorze