Recept

Shopify → fakturautkast

Skelettet till en liten worker som tar emot Shopifys orders/create och lämnar ordern till din bokföring. Den körs hos dig — på Vercel, Cloudflare Workers, Netlify eller din egen server. Vi driftar inget led.

Varför WooCommerce får en mall och Shopify inte

Det här är inte en fråga om vilken plattform vi tycker om. WooCommerce går att starta i en Docker-container. Vår CI bygger två riktiga Woo-butiker, en som matar in priserna exklusive moms och en som matar in dem inklusive, lägger samma order i båda och kräver identiska nettobelopp. Det provet är hela skälet till att vi vågar drifta den mallen: momsantagandet är mätt mot butiken, inte läst i dokumentationen.

Shopify går inte att starta. Det finns ingen container, ingen självbetjänad butik att köra provet mot i CI. En adapter vi skrev efter dokumentationen och aldrig körde skarpt vore en gissning med kod runt omkring — och en gissning om moms blir din myndighetsanmärkning, inte vår. Därför får du kontraktet, skelettet och den enda mening i receptet som är värd pengar, nedan.

Meningen som är värd pengar

Shopify och WooCommerce menar motsatta saker med sina radpriser.

WooCommerce levererar alltid netto i radens total och lägger momsen bredvid i total_tax — även när butiken matar in priserna inklusive moms. Woos prices_include_tax ska därför aldrig skickas vidare.

Shopifys line_items[].price inkluderar momsen när taxes_included är sant. Där ska flaggan skickas vidare, rakt av.

Samma tal, motsatt betydelse. Det är precis därför pricesIncludeVat är obligatoriskt i vårt kontrakt: det går inte att se på ett belopp vilket det är, och att anta fel gör varje rad 25 procent fel.

Mappningen

function mapShopifyOrder(o) {
  const rows = [];

  for (const line of o.line_items ?? []) {
    // rate är ett DECIMALTAL: 0.06 = 6 procent, 0.25 = 25 procent.
    const rate = line.tax_lines?.[0]?.rate ?? 0;
    // price är per STYCK. Rabatten ligger i total_discount pa hela raden.
    const rabattPerStyck = Number(line.total_discount ?? 0) / line.quantity;
    rows.push({
      description: line.title,
      quantity: line.quantity,
      unitPrice: Number(line.price) - rabattPerStyck,
      vatRate: Math.round(rate * 100),
      articleNumber: line.sku || undefined,
    });
  }

  for (const s of o.shipping_lines ?? []) {
    if (Number(s.price) === 0) continue;
    rows.push({
      description: s.title || "Frakt",
      quantity: 1,
      unitPrice: Number(s.price),
      vatRate: Math.round((s.tax_lines?.[0]?.rate ?? 0) * 100),
    });
  }

  const b = o.billing_address ?? {};

  return {
    externalId: `shopify-${o.id}`,
    orderDate: (o.created_at ?? "").slice(0, 10),
    currency: o.currency,

    // HÄR SKICKAS FLAGGAN VIDARE, till skillnad från i WooCommerce.
    pricesIncludeVat: Boolean(o.taxes_included),

    total: Number(o.total_price),
    customer: {
      name: b.company || b.name || `Kund ${o.id}`,
      email: o.email || undefined,
      countryCode: b.country_code || "SE",
      address: b.address1 || undefined,
      postalCode: b.zip || undefined,
      city: b.city || undefined,
      // Shopify har inget standardfält för momsreg.nr heller. Det ligger i
      // note_attributes eller ett metafält, beroende på vilken app butiken har.
      vatNumber: (o.note_attributes ?? []).find((a) => a.name === "vat_number")?.value,
    },
    rows,
    note: `Shopify-order ${o.order_number}`,
  };
}

rate är ett decimaltal. Shopify skriver "rate": 0.06 för sex procent, inte 6. Skickas talet rakt in i vatRate blir satsen noll efter avrundning, och hela momsen försvinner.

price är per styck — till skillnad från Woos total, som är hela raden. Rabatten ligger separat i total_discount på raden, och den måste dras av; annars blir fakturan högre än det kunden betalade och totalkontrollen spärrar ordern.

Frakten blir en egen rad, precis som i Woo. Utelämnas den summerar raderna kort med fraktbeloppet, och ordern spärras.

Momsreg.nr har inget standardfält. Beroende på vilken app butiken har ligger det i note_attributes eller i ett metafält. Utan det räknas en köpare i ett annat EU-land som konsument, och ordern spärras som unionsintern distansförsäljning (OSS).

Verifieringen och workern

import crypto from "node:crypto";

/**
 * SIGNATUREN GÄLLER RÅ KROPP.
 * Att signera den parsade och åter-JSON:ade kroppen är DET klassiska felet:
 * nyckelordning, mellanrum och talformatering ändras, och signaturen stämmer
 * aldrig. Läs kroppen som text EN gång och använd samma sträng till båda.
 */
export default async function handler(request) {
  const raw = await request.text();

  const forvantad = crypto
    .createHmac("sha256", process.env.SHOPIFY_CLIENT_SECRET)
    .update(raw, "utf8")
    .digest("base64");

  const given = request.headers.get("x-shopify-hmac-sha256") ?? "";
  const a = crypto.createHash("sha256").update(forvantad).digest();
  const b = crypto.createHash("sha256").update(given).digest();
  if (!crypto.timingSafeEqual(a, b)) {
    return new Response("nej", { status: 401 });
  }

  // Shopify LEVERERAR OM vid fel svar, och samma order kan komma två gånger.
  // X-Shopify-Event-Id är dubblettnyckeln. Vårt intag är dessutom idempotent
  // pa externalId, sa ett omforsok kostar ingenting — men logga id:t.
  const eventId = request.headers.get("x-shopify-event-id");

  const order = JSON.parse(raw);
  const res = await fetch(`${process.env.DEBEA_URL}/api/inbound/order`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.DEBEA_API_KEY}`,
    },
    body: JSON.stringify(mapShopifyOrder(order)),
  });

  // 422 betyder att bokföringen tog emot ordern men spärrade den — den är
  // sparad där med sitt skäl. Svara 2xx: den är inte förlorad.
  return new Response("ok", { status: res.ok || res.status === 422 ? 200 : 503 });
}

Signaturen räknas på RÅ kropp. Att signera den parsade och åter-JSON:ade kroppen är det klassiska felet — nyckelordning och talformatering ändras, och signaturen stämmer aldrig. Läs kroppen som text en gång och använd samma sträng både till HMAC:en och till JSON.parse.

Jämför tidskonstant. En === på två strängar avbryter vid första olika tecknet, och skillnaden i svarstid räcker för att gissa signaturen tecken för tecken.

Shopify levererar om vid fel svarskod. X-Shopify-Event-Id är dubblettnyckeln. Vårt intag är dessutom idempotent på externalId, så en omleverans ger samma fakturautkast tillbaka i stället för ett andra.

Kedjan

  1. Skapa en API-nyckel i bokföringen med behörigheten Ta emot underlag under Inställningar → API-nycklar. Den kan lämna in ordrar och ingenting annat.
  2. Deploya workern ovan och sätt DEBEA_URL, DEBEA_API_KEY och SHOPIFY_CLIENT_SECRET.
  3. I Shopify-appen: prenumerera på ämnet orders/create med workerns adress. Klienthemligheten är appens, och det är den HMAC:en räknas med.
  4. Lägg en testorder och titta i bokföringen under Order.

Vidare