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
Powiązany case
Automatyzacja faktur z Gmail do wFirma
Poradnik
Gmail → wFirma — pełny tutorial z Make.com

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.
