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