למפתחים · מתשלום למסמך

חשבונית אוטומטית ל-WooCommerce על כל הזמנה ששולמה

חנות WooCommerce יודעת מתי הזמנה שולמה. מה שחסר לה הוא חשבונית מס קבלה תקינה, עם מספור רציף, שנשלחת ללקוח. כאן החיבור: webhook אחד מהחנות, ומסמך על כל הזמנה ששולמה.

עודכן ב- · 5 דקות קריאה · נכתב על ידי בר אסולין ובר אביזמר, מייסדי BillOS

מגדירים webhook בחנות

בלוח הבקרה של וורדפרס: WooCommerce, הגדרות, מתקדם, Webhooks, הוספת webhook.

שדהערך
סטטוספעיל
נושאOrder updated
כתובת למסירהhttps://your-server.example.com/hooks/woocommerce
סודמחרוזת אקראית ארוכה. אותה שומרים גם בשרת, במשתנה סביבה.

עם השמירה החנות שולחת בקשת בדיקה (ping) לכתובת. גוף ה-webhook הוא ההזמנה בדיוק כמו שה-REST API של WooCommerce מחזיר אותה.

מאמתים את החתימה

הכותרת X-WC-Webhook-Signature היא HMAC-SHA256 של הגוף הגולמי עם הסוד, בקידוד base64.

woocommerce.mjsGitHub
// X-WC-Webhook-Signature: base64 HMAC-SHA256 of the raw body, keyed with the webhook's secret.
export function verify({ headers, rawBody, secret }) {
  const sent = Buffer.from(String(headers['x-wc-webhook-signature'] || ''), 'base64')
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest()
  return sent.length === expected.length && crypto.timingSafeEqual(sent, expected)
}

מההזמנה לתשלום

כל הסכומים בהזמנה הם מחרוזות עשרוניות, ומומרים לאגורות פעם אחת. בכל שורה, total הוא הסכום אחרי הנחות ולפני מע״מ, ו-total_tax הוא המע״מ שלה. מה שהלקוח שילם על השורה הוא החיבור של השניים. המשלוח והעמלות נכנסים כשורות משלהם.

woocommerce.mjsGitHub
// An order's amounts are decimal strings. A line's `total` is before tax and `total_tax` is
// its tax, so what the customer paid for a line is the two together.
export function parse({ rawBody }) {
  const order = JSON.parse(rawBody)
  // There is no "order paid" topic. "Order updated" fires on every change, and on a refund.
  // Money arrived when date_paid is set; the order id is the idempotency key, so the tenth
  // update of a paid order changes nothing.
  if (!order.date_paid || !['processing', 'completed', 'refunded'].includes(order.status)) return null

  const withTax = (row, net = 'total') => toMinor(row[net]) + toMinor(row.total_tax || '0')
  const items = [
    ...order.line_items.map((li) => ({ description: li.quantity > 1 ? `${li.name} × ${li.quantity}` : li.name, quantity: 1, unitAmount: withTax(li) })),
    ...order.shipping_lines.filter((s) => withTax(s) > 0).map((s) => ({ description: s.method_title || 'משלוח', quantity: 1, unitAmount: withTax(s) })),
    ...(order.fee_lines || []).map((f) => ({ description: f.name, quantity: 1, unitAmount: withTax(f) })),
  ]
  const b = order.billing || {}
  return {
    // Each refund of the order is listed on it, with a negative total.
    refunds: (order.refunds || []).map((r) => ({
      source: name, id: String(r.id), paymentId: `order-${order.id}`, amount: Math.abs(toMinor(r.total)), reason: r.reason || 'החזר כספי',
    })),
    payment: {
      source: name,
      id: `order-${order.id}`,
      amount: toMinor(order.total),
      currency: order.currency,
      method: METHODS[order.payment_method] || 'card',
      description: `הזמנה ${order.number || order.id}`,
      customer: {
        name: b.company || [b.first_name, b.last_name].filter(Boolean).join(' '),
        email: b.email,
        phone: b.phone,
      },
      items,
    },
  }
}
// The store's payment method ids, as BillOS names them. Anything else prints as a card.
const METHODS = { bacs: 'transfer', cheque: 'check', cod: 'cash', bit: 'bit' }

״Order updated״ נשלח על כל שינוי בהזמנה: הערה, שינוי כתובת, שינוי סטטוס. הכסף התקבל כשהשדה date_paid מלא.

מהתשלום לחשבונית

מכאן הקוד זהה לכל מקור תשלום. הפריטים מודפסים על המסמך כשהסכום שלהם שווה בדיוק לסכום ההזמנה. אם לא, למשל בגלל כלל עיגול של תוסף, המסמך מופק עם שורה אחת על הסכום ששולם.

billos.mjsGitHub
export async function issueForPayment(payment, { apiKey, businessId }) {
  const { business } = await call('GET', `/businesses/${businessId}`, { apiKey })
  // The money is already in, so the document carries the sale and its payment together:
  // a tax invoice/receipt (320). An exempt dealer (osek patur) may not issue a tax invoice,
  // and gives a receipt (400) instead.
  const docType = business.dealerType === 'patur' ? 400 : 320

  // The items are printed only when they add up to what was charged. Shipping, a coupon or a
  // rounding rule on the shop's side would otherwise make a document that disagrees with the
  // payment, and the payment is the fact.
  const itemsTotal = payment.items.reduce((sum, i) => sum + Math.round(i.quantity * i.unitAmount), 0)
  const lines = payment.items.length && itemsTotal === payment.amount
    ? payment.items.map((i) => ({ description: i.description, quantity: i.quantity, unitPriceExVat: i.unitAmount }))
    : [{ description: payment.description, quantity: 1, unitPriceExVat: payment.amount }]

  const { name, email, phone, taxId } = payment.customer
  const { document } = await call('POST', `/businesses/${businessId}/documents`, {
    apiKey,
    // The source's own payment id. A notification delivered twice lands on the same document.
    idempotencyKey: `${payment.source}:${payment.id}`,
    body: {
      docType,
      // The customer was charged a VAT-inclusive price. In gross mode unitPriceExVat carries
      // that price, the total is exactly what was paid, and the VAT is derived from it.
      priceMode: 'gross',
      ...(payment.currency === 'ILS' ? {} : { currency: payment.currency }),
      party: { name: name || email || 'לקוח', ...(email ? { email } : {}), ...(phone ? { phone } : {}), ...(taxId ? { taxId } : {}) },
      lines,
      payments: [{ method: payment.method, amount: payment.amount }],
    },
  })
  // A retry of a notification that was already handled: the document exists and is final.
  if (document.status === 'issued') return document
  // Issuing gives the document its number and signs the PDF. From here it never changes.
  const issued = await call('POST', `/businesses/${businessId}/documents/${document.id}/issue`, { apiKey })
  return issued.document
}

החזר בחנות הוא זיכוי

כשמבצעים החזר ב-WooCommerce, ההזמנה מתעדכנת ואותו webhook נשלח שוב, עם ההחזר ברשימת refunds. לכל החזר יש מזהה משלו, והוא מפתח ה-idempotency של הזיכוי.

billos.mjsGitHub
// An issued document is never edited or deleted. Money that went back is a credit note that
// names the original: in full when no amount is given, in part otherwise.
export async function creditForRefund(refund, { apiKey, businessId, documentId }) {
  const { document } = await call('POST', `/businesses/${businessId}/documents/${documentId}/credit-note`, {
    apiKey,
    // The source's refund id: a refund notified twice is one credit note.
    idempotencyKey: `${refund.source}:refund:${refund.id}`,
    body: {
      reason: refund.reason || 'החזר כספי',
      ...(refund.amount ? { amount: refund.amount } : {}),
      // The money was handed back, so an invoice/receipt also gets its negative receipt.
      refund: true,
    },
  })
  return document
}

כדאי לדעת

  • המסירה רצה ברקע. WooCommerce שולח webhooks דרך המתזמן של וורדפרס, כך שההתראה יכולה להגיע כמה שניות אחרי התשלום.
  • חמש מסירות שנכשלו ברצף מכבות את ה-webhook. אם השרת שלכם היה למטה, בודקים בחנות שה-webhook עדיין פעיל.
  • אמצעי התשלום. הקוד ממפה את מזהי השיטות של החנות: העברה בנקאית, המחאה ומזומן. כל שיטה אחרת מודפסת ככרטיס אשראי, ואפשר להוסיף את המזהים של תוספי הסליקה שלכם.
  • לקוח עסקי. שם החברה מפרטי החיוב מודפס כשם הלקוח. כדי שחשבונית מעל הסף תקבל מספר הקצאה, אוספים מספר עוסק בקופה ושולחים אותו בשדה taxId.

שאלות נפוצות

לאיזה אירוע של WooCommerce מאזינים?

לנושא ״Order updated״. ב-WooCommerce אין נושא נפרד להזמנה ששולמה, ולכן הקוד בודק בכל עדכון אם השדה date_paid מלא ואם הסטטוס הוא processing או completed.

ההזמנה מתעדכנת הרבה פעמים. תיווצר חשבונית בכל עדכון?

לא. מפתח ה-idempotency הוא מספר ההזמנה, ולכן רק העדכון הראשון של הזמנה ששולמה מפיק מסמך. כל עדכון אחריו מחזיר את אותו מסמך.

המחירים בחנות מוגדרים בלי מע״מ. זה משנה?

לא. בכל שורה של הזמנה יש סכום לפני מע״מ וסכום המע״מ, והקוד מחבר אותם. המסמך מופק על הסכום שהלקוח שילם בפועל.

מה עם הזמנה בהעברה בנקאית או במזומן?

הזמנה כזאת מקבלת date_paid כשמעבירים אותה בחנות לסטטוס ״בטיפול״ או ״הושלם״, ואז מופק המסמך. אמצעי התשלום מודפס עליו: העברה בנקאית, מזומן או כרטיס אשראי.

מה קורה כשמבצעים החזר בחנות?

ההחזר מגיע באותו webhook, ברשימת ההחזרים של ההזמנה. הקוד מפיק חשבונית זיכוי על סכום ההחזר, פעם אחת לכל החזר.

חייבים שרת נפרד?

לחיבור הזה כן: ה-webhook נשלח מהחנות לכתובת שמקבלת אותו. שרת קטן או פונקציית ענן מספיקים.

המסמך הראשון, עוד היום

מפתח Sandbox בחינם, בלי כרטיס אשראי. הקוד שעובד מול הבדיקות עובד מול live בלי שינוי.

מפתח Sandbox בחינם