Fakturowanie w sklepie headless: webhooki na wejściu, numery faktur z powrotem
Poradniki

Fakturowanie w sklepie headless: webhooki na wejściu, numery faktur z powrotem

cmssy nie wystawia faktur - robi to Twój system fakturowy. Oto pętla, która je łączy: faktura po płatności, numer zapisany z powrotem, faktura korygująca przy zwrocie, bezpiecznie nawet wtedy, gdy webhook przyjdzie dwa razy.

Z
Zespół Cmssy
6 min read

Fakturowanie w sklepie headless: webhooki na wejściu, numery faktur z powrotem

cmssy zostawia fakturowanie Twojemu własnemu systemowi. Oto pętla webhooków, która wystawia fakturę po płatności, zapisuje jej numer w zamówieniu i dodaje fakturę korygującą przy zwrocie - bezpiecznie, nawet gdy dostawa się powtórzy.

Każdy sklep potrzebuje faktur i prawie każdy sklep ma już system, który je wystawia: program księgowy, krajową bramkę e-fakturowania, dostawcę, przy którym upiera się Twoja księgowa. cmssy nie próbuje go zastąpić. Robi trzy mniejsze rzeczy: mówi Twojemu systemowi fakturowemu, kiedy ma działać, przechowuje w zamówieniu to, co ten system wystawił, i pokazuje wynik kupującemu.

Ten wpis buduje taką pętlę: wystaw fakturę, gdy zamówienie zostanie opłacone, zapisz jej numer z powrotem i wystaw fakturę korygującą, gdy pieniądze zostaną zwrócone.

Diagram sekwencji: cmssy wysyła order.paid do Twojego handlera, handler prosi system fakturowy o fakturę, a potem zapisuje numer przez order.recordInvoice; przy order.refunded wystawia fakturę korygującą i zapisuje ją przez order.recordInvoiceCorrection.
Cała pętla: dwa webhooki na wejściu, dwie mutacje z powrotem.

Model: jedna faktura, potem korekty

Każde zamówienie ma listę invoices, od najstarszego wpisu. Każdy wpis ma kind (invoice lub correction), number, opcjonalny url do PDF-a, opcjonalny provider i znacznik czasu issuedAt. Zasady są krótkie:

  • Jedna faktura na zamówienie. Ponowny zapis tego samego numeru niczego nie zmienia. Zapis innego numeru jest odrzucany, bo zamówienie, które nagle ma dwie faktury, to błąd księgowy, a nie aktualizacja.
  • Anulowanego zamówienia nie da się zafakturować. Gdy najpierw anulujesz, nie ma czego fakturować.
  • Korekta wymaga faktury. Faktura korygująca jest dozwolona także w zamówieniach anulowanych i zwróconych, jej numer musi być w zamówieniu nowy, a jej powtórzenie niczego nie zmienia.
  • Najwyżej 20 wpisów na zamówienie, wliczając korekty.
Lista invoices w zamówieniu: jedna faktura FV/2026/1042 i jedna faktura korygująca KOR/2026/0042, każda z polami number, url, provider i issuedAt.
To, co przechowuje zamówienie. Kupujący widzi tę samą listę w swoim zamówieniu.

Zespół może zapisać jedno i drugie ze strony zamówienia w panelu admina, przez mutacje GraphQL order.recordInvoice i order.recordInvoiceCorrection albo narzędziami MCP record_order_invoice i record_order_invoice_correction. Wszystkie wymagają uprawnienia orders:manage.

Krok 1: zasubskrybuj właściwe zdarzenia

Utwórz endpoint webhooka dla swojego handlera i zasubskrybuj go na:

  • order.paid - zamówienie jest w pełni opłacone; czas wystawić fakturę.
  • order.refunded i order.partially_refunded - pieniądze wróciły do kupującego; czas na fakturę korygującą.
  • opcjonalnie order.invoiced i order.invoice_corrected - wysyłane po zapisaniu numeru, przydatne do wysłania PDF-a e-mailem albo synchronizacji księgi.

Subskrypcja zdarzeń order.* wymaga orders:view oprócz webhooks:manage. Każdy payload niesie zamówienie z jego invoices, total, tax, currency, taxTreatment (z adnotacją prawną do umieszczenia na fakturze z odwrotnym obciążeniem lub eksportowej), kupującego i jego firmę.

Krok 2: handler

Oto trasa Next.js. issueInvoice i issueCorrection zastępują API Twojego dostawcy; cała reszta to cmssy.

// app/api/invoicing/route.ts
import { verifyCmssyWebhook, CmssyWebhookError } from "@cmssy/next";
import { issueInvoice, issueCorrection } from "@/lib/invoicing";

const RECORD = `mutation ($input: RecordOrderInvoiceInput!) {
  order { recordInvoice(input: $input) { id } }
}`;
const CORRECT = `mutation ($input: RecordOrderInvoiceInput!) {
  order { recordInvoiceCorrection(input: $input) { id } }
}`;
const CURRENT = `query ($id: ID!) {
  order { get(id: $id) { invoices { kind number } } }
}`;

async function cmssy(query: string, variables: object) {
  const res = await fetch("https://api.cmssy.io/graphql", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${process.env.CMSSY_API_TOKEN}`,
      "x-workspace-id": process.env.CMSSY_WORKSPACE_ID!,
    },
    body: JSON.stringify({ query, variables }),
  });
  const json = await res.json();
  if (json.errors?.length) throw new Error(json.errors[0].message);
  return json.data;
}

export async function POST(request: Request) {
  const body = await request.text();
  let event;
  try {
    event = await verifyCmssyWebhook({
      body,
      signatureHeader: request.headers.get("x-cmssy-signature"),
      secret: process.env.CMSSY_WEBHOOK_SECRET!,
    });
  } catch (error) {
    if (error instanceof CmssyWebhookError) {
      return new Response(null, { status: 400 });
    }
    throw error;
  }

  const order = event.data.order;
  const { order: { get: current } } = await cmssy(CURRENT, { id: order.id });
  const invoice = current.invoices.find((i) => i.kind === "invoice");

  if (event.event === "order.paid" && !invoice) {
    const issued = await issueInvoice(order, { idempotencyKey: order.id });
    await cmssy(RECORD, {
      input: { orderId: order.id, number: issued.number, url: issued.pdfUrl, provider: "my-invoicing" },
    });
  }

  if (event.event.endsWith("refunded") && invoice) {
    const key = `${order.id}:${order.refundedAmount}`;
    const issued = await issueCorrection(order, invoice, {
      refundedTotal: order.refundedAmount,
      idempotencyKey: key,
    });
    await cmssy(CORRECT, {
      input: { orderId: order.id, number: issued.number, url: issued.pdfUrl, provider: "my-invoicing" },
    });
  }

  return new Response(null, { status: 204 });
}

Token to token API workspace'u (cs_…) utworzony przez osobę, której rola ma orders:manage. Trzymaj go po stronie serwera; może zrobić wszystko, co ta osoba.

Krok 3: zadbaj, by podwójne uruchomienie było bezpieczne

Webhooki są dostarczane co najmniej raz i nie po kolei. Dostawa może zostać ponowiona po timeoucie, którego Twój handler nie zauważył, a powolna ponowna próba może dotrzeć po nowszym zdarzeniu. Trzy rzeczy w handlerze powyżej istnieją właśnie po to:

  • Czytaj bieżące zamówienie, nie payload. Payload to migawka z chwili zdarzenia. Dwie dostawy tego samego order.paid mówią „jeszcze bez faktury”; dopiero świeży odczyt powie drugiej, że pierwsza już skończyła.
  • Daj dostawcy klucz idempotencji. Strona cmssy jest bezpieczna - ten sam numer wysłany dwa razy niczego nie zapisze podwójnie - ale to, czy dostawca wystawi dwie faktury, jest już po Twojej stronie. Jeśli Twój dostawca obsługuje klucze, kluczuj fakturę po id zamówienia, a fakturę korygującą po zwróconej kwocie; jeśli nie, przed wystawieniem poszukaj istniejącego dokumentu po własnym identyfikatorze.
  • Traktuj odmowę jako informację. Jeśli recordInvoice odpowie, że zamówienie ma już fakturę z innym numerem, dwa handlery się ścigały, a dostawca ma duplikat. Anuluj go u dostawcy; cmssy zachowało pierwszą.

Zwracaj 2xx dopiero po zapisaniu numeru. Nieudana próba jest ponawiana przez około 27 godzin, więc krótka awaria u dostawcy naprawia się sama.

Co widzi kupujący

Sklep czyta tę samą listę w zamówieniu kupującego, więc strona zamówienia może linkować fakturę i każdą fakturę korygującą, gdy tylko zostaną zapisane - dla zalogowanego klienta i dla gościa, który czyta swoje zamówienie tokenem z checkoutu. Nie ma czego synchronizować.

Żądania usunięcia danych

Gdy zespół anonimizuje zakończone zamówienie, cmssy usuwa dane osobowe kupującego i linki w jego fakturach, a zachowuje numery faktur, żeby księgi wciąż się zgadzały. Twój system fakturowy ma własną kopię; webhook order.anonymized to sygnał, by obsłużyć ją tam zgodnie z Twoimi zasadami retencji.

Bez kodu

Nie każdy sklep potrzebuje handlera. Jeśli faktury wystawiasz ręcznie, zapisz numer na stronie zamówienia albo poproś agenta połączonego przez MCP: „zapisz fakturę FV/2026/1042 w zamówieniu 1042 z tym linkiem do PDF-a”. Zasady są te same, podobnie jak webhook, który to wywołuje.

Pełny opis jest w dokumentacji commerce, lista zdarzeń i szczegóły podpisów w dokumentacji webhooków, a wpis o webhookach wchodzi głębiej w ponowienia i weryfikację.