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

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

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

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

נרשמים ל-webhook

יש שתי דרכים. חנות אחת, בלי אפליקציה: בניהול החנות, Settings, Notifications, Create webhook, עם האירוע Order payment ופורמט JSON. אפליקציה שמשרתת כמה חנויות נרשמת בקובץ ההגדרות שלה:

shopify.app.toml
[[webhooks.subscriptions]]
topics = ["orders/paid", "refunds/create"]
uri = "/hooks/shopify"

אפליקציה שקוראת הזמנות נוגעת בפרטי לקוחות, ולכן צריכה לבקש גישה לנתוני לקוחות מוגנים בלוח השותפים של Shopify.

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

הכותרת X-Shopify-Hmac-Sha256 היא HMAC-SHA256 של הגוף הגולמי, בקידוד base64. המפתח הוא ה-client secret של האפליקציה, או המפתח שמוצג במסך ההתראות של החנות.

shopify.mjsGitHub
// X-Shopify-Hmac-Sha256: base64 HMAC-SHA256 of the raw body, keyed with the app's client secret.
export function verify({ headers, rawBody, secret }) {
  const sent = Buffer.from(String(headers['x-shopify-hmac-sha256'] || ''), 'base64')
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest()
  return sent.length === expected.length && crypto.timingSafeEqual(sent, expected)
}

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

הסכומים מגיעים כמחרוזות עשרוניות. total_price הוא הסכום הכולל: פריטים, הנחות, משלוח ומיסים. מחיר של שורה הוא ליחידה ולפני הנחות, והשדה taxes_included אומר אם הוא כבר כולל מע״מ.

shopify.mjsGitHub
export function parse({ headers, rawBody }) {
  const topic = headers['x-shopify-topic']
  const o = JSON.parse(rawBody)

  if (topic === 'orders/paid') {
    // A line's price is per unit, before its discounts. When the shop's prices do not
    // include tax, the tax of the line is added to it.
    const taxOf = (row) => (o.taxes_included ? 0 : (row.tax_lines || []).reduce((sum, t) => sum + toMinor(t.price), 0))
    const items = [
      ...o.line_items.map((li) => ({
        description: li.quantity > 1 ? `${li.title} × ${li.quantity}` : li.title,
        quantity: 1,
        unitAmount: toMinor(li.price) * li.quantity - toMinor(li.total_discount || '0') + taxOf(li),
      })),
      ...(o.shipping_lines || []).filter((s) => toMinor(s.price) > 0).map((s) => ({ description: s.title || 'משלוח', quantity: 1, unitAmount: toMinor(s.price) + taxOf(s) })),
    ]
    const c = o.customer || {}
    const bill = o.billing_address || {}
    return {
      payment: {
        source: name,
        id: `order-${o.id}`,
        amount: toMinor(o.total_price),
        currency: o.currency,
        method: 'card',
        description: `הזמנה ${o.name || o.id}`,
        customer: {
          name: bill.company || bill.name || [c.first_name, c.last_name].filter(Boolean).join(' '),
          email: o.email || c.email,
          phone: o.phone || bill.phone || c.phone,
        },
        items,
      },
    }
  }

  if (topic === 'refunds/create') {
    const amount = (o.transactions || []).filter((t) => t.kind === 'refund' && t.status === 'success').reduce((sum, t) => sum + toMinor(t.amount), 0)
    if (!amount) return null // a restock with no money returned
    return { refund: { source: name, id: String(o.id), paymentId: `order-${o.order_id}`, amount, reason: o.note || 'החזר כספי' } }
  }
  return null
}

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

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

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
}

החזר ב-Shopify הוא זיכוי

ה-webhook refunds/create נושא את מספר ההזמנה ואת העסקאות של ההחזר. מזהה ההחזר הוא מפתח ה-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
}

כדאי לדעת

  • חמש שניות. Shopify מחכה חמש שניות לתשובה, וכל תשובה שאינה בטווח 200 נחשבת כישלון, כולל הפניה.
  • שמונה ניסיונות בארבע שעות. אחרי שמונה כישלונות רצופים, מנוי שנוצר דרך ה-API נמחק. כדאי להריץ גם בדיקה תקופתית שמשווה הזמנות ששולמו למסמכים שהופקו.
  • מזהה לכל מסירה. הכותרת X-Shopify-Webhook-Id מזהה מסירה. כאן אין בה צורך, כי מפתח ה-idempotency הוא מספר ההזמנה.
  • לקוח עסקי. שם החברה מכתובת החיוב מודפס כשם הלקוח. כדי שחשבונית מעל הסף תקבל מספר הקצאה, אוספים מספר עוסק ושולחים אותו בשדה taxId.

שאלות נפוצות

לאיזה webhook של Shopify מאזינים?

ל-orders/paid, שנשלח כשהזמנה שולמה במלואה. להחזרים מאזינים ל-refunds/create.

איך מגדירים webhook בלי לבנות אפליקציה?

בניהול החנות: Settings, Notifications, Create webhook. בוחרים את האירוע Order payment, פורמט JSON ואת הכתובת של השרת. המפתח לאימות החתימה מוצג באותו מסך.

המחירים בחנות לא כוללים מע״מ. מה מופק?

מסמך על הסכום שהלקוח שילם. כש-taxes_included הוא false, הקוד מוסיף לכל שורה את המס שלה, וכך סכום השורות שווה לסכום ההזמנה.

מה קורה כשיש הנחה על ההזמנה?

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

מה קורה בהחזר חלקי?

ה-webhook של ההחזר כולל את העסקאות שבהן הוחזר כסף. הקוד מסכם אותן ומפיק זיכוי על הסכום הזה. החזרת מלאי בלי החזר כספי לא מפיקה דבר.

החנות מוכרת בדולרים. אפשר?

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

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

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

מפתח Sandbox בחינם