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

חשבונית מס אוטומטית על כל תשלום ב-Stripe

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

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

מגדירים את ה-webhook ב-Stripe

ב-Stripe Workbench, תחת Webhooks, יוצרים יעד חדש (event destination) עם הכתובת של השרת שלכם, ובוחרים ארבעה אירועים:

אירועמתי
checkout.session.completedלקוח סיים תשלום ב-Checkout או ב-Payment Link.
checkout.session.async_payment_succeededאמצעי תשלום שמתברר מאוחר יותר אישר את התשלום.
refund.created, refund.updatedנוצר החזר, או שהסטטוס שלו השתנה.

Stripe מציג סוד חתימה שמתחיל ב-whsec_. שומרים אותו במשתנה סביבה. בזמן פיתוח, ה-CLI של Stripe מעביר את האירועים למחשב שלכם:

Terminal
stripe listen --forward-to localhost:3000/hooks/stripe

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

כל אירוע מגיע עם הכותרת Stripe-Signature: חותמת זמן, ו-HMAC-SHA256 של חותמת הזמן, נקודה והגוף הגולמי, עם סוד החתימה. מחשבים את אותו ערך ומשווים. חשוב לחתום על הבייטים כמו שהגיעו: JSON שעבר פענוח וקידוד מחדש לא ייתן את אותה חתימה.

stripe.mjsGitHub
// Stripe-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the endpoint's
// signing secret (whsec_…)>. Verified on the raw bytes, before the JSON is trusted. The header
// may carry more than one v1 and other entries; only t and v1 are read.
export function verify({ headers, rawBody, secret, now = Date.now() }) {
  const parts = String(headers['stripe-signature'] || '').split(',').map((p) => p.split('='))
  const t = parts.find(([k]) => k === 't')?.[1]
  const sent = parts.filter(([k]) => k === 'v1').map(([, v]) => v)
  if (!t || !sent.length) return false
  if (Math.abs(now / 1000 - Number(t)) > 300) return false // older than five minutes: a replay
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
  return sent.some((v) => v.length === expected.length && crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected)))
}

מהאירוע לתשלום

הסכומים ב-Stripe הם מספרים שלמים ביחידה הקטנה של המטבע, כלומר אגורות בשקלים, בדיוק כמו ב-BillOS. amount_total הוא מה שהלקוח שילם, אחרי הנחות ומיסים.

stripe.mjsGitHub
// Stripe amounts are already integers in the currency's minor unit (agorot for ILS).
export function parse({ rawBody }) {
  const event = JSON.parse(rawBody)
  const o = event.data.object

  if (event.type === 'checkout.session.completed' || event.type === 'checkout.session.async_payment_succeeded') {
    // "completed" also fires for a bank debit that has not cleared yet: only "paid" is money.
    if (o.payment_status !== 'paid') return null
    return {
      payment: {
        source: name,
        // The PaymentIntent is the payment itself; a refund names it too.
        id: o.payment_intent || o.id,
        amount: o.amount_total,
        currency: o.currency.toUpperCase(),
        method: 'card',
        description: o.metadata?.description || `הזמנה ${o.client_reference_id || o.id}`,
        customer: {
          name: o.customer_details?.name,
          email: o.customer_details?.email,
          phone: o.customer_details?.phone,
          // A business customer's number, when you collected it in a custom field.
          taxId: o.custom_fields?.find((f) => f.key === 'taxid')?.text?.value,
        },
        // The session event carries no line items. lineItems() below fetches them.
        items: [],
        sessionId: o.id,
      },
    }
  }

  // Every refund is an object of its own, with its own amount: a second partial refund of the
  // same payment is a second credit note. The refund id is the idempotency key, so
  // refund.created and a later refund.updated land on one.
  if ((event.type === 'refund.created' || event.type === 'refund.updated') && o.status === 'succeeded') {
    return { refund: { source: name, id: o.id, paymentId: o.payment_intent, amount: o.amount, reason: 'החזר כספי' } }
  }
  return null
}

שני דברים שקל לפספס. האירוע completed נשלח גם כשהתשלום עוד לא התברר, ולכן בודקים את payment_status. ומפתח ה-idempotency הוא ה-PaymentIntent ולא מזהה האירוע: אותו תשלום יכול להגיע בשני אירועים שונים.

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

מכאן הקוד זהה לכל מקור תשלום: בוחרים את סוג המסמך לפי סוג העוסק, יוצרים טיוטה עם מפתח ה-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
}

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

הפריטים

האירוע של Checkout לא כולל את מה שנקנה. מי שרוצה שהפריטים יודפסו על החשבונית מביא אותם מ-Stripe. amount_total של שורה הוא מה ששולם עליה, כולל מס והנחות:

stripe.mjsGitHub
// What was bought, from the Checkout Session. amount_total on a line is what the customer
// paid for it, tax and discounts included.
export async function lineItems(sessionId, stripeSecretKey) {
  const res = await fetch(`https://api.stripe.com/v1/checkout/sessions/${sessionId}/line_items?limit=100`, {
    headers: { Authorization: `Bearer ${stripeSecretKey}` },
  })
  if (!res.ok) return []
  const { data } = await res.json()
  return data.map((li) => ({ description: li.description, quantity: 1, unitAmount: li.amount_total, units: li.quantity }))
    .map(({ units, ...i }) => (units > 1 ? { ...i, description: `${i.description} × ${units}` } : i))
}

החזר ב-Stripe הוא זיכוי ב-BillOS

כל החזר הוא אובייקט משלו, עם סכום משלו ועם ה-PaymentIntent שהוא שייך לו. מזהה ההחזר הוא מפתח ה-idempotency של הזיכוי, כך ש-refund.created ו-refund.updated על אותו החזר מפיקים זיכוי אחד.

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
}

כדאי לדעת

  • עונים מהר. כשיש success_url, Checkout מחכה עד עשר שניות לתשובה שלכם לפני שהוא מחזיר את הלקוח לאתר.
  • Stripe מנסה שוב. בסביבה האמיתית, עד שלושה ימים במרווחים הולכים וגדלים. בגלל מפתח ה-idempotency ניסיון חוזר לא יוצר מסמך שני.
  • הסדר לא מובטח. אירוע על החזר יכול להגיע לפני האירוע על התשלום. החזר לתשלום שעוד לא הופק לו מסמך נענה ב-200 ולא מפיק דבר.
  • לקוח עסקי. אם אתם אוספים מספר עוסק ב-Checkout, שולחים אותו בשדה taxId, והחשבונית תקבל מספר הקצאה כשצריך.

שאלות נפוצות

האם האישור ש-Stripe שולח ללקוח מספיק?

האישור של Stripe הוא אישור תשלום. חשבונית מס או קבלה של עסק ישראלי מופקת בתוכנה לניהול ספרים, עם מספור רציף, וזה מה שהחיבור הזה מוסיף.

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

ל-checkout.session.completed, ולאמצעי תשלום שמתבררים מאוחר יותר גם ל-checkout.session.async_payment_succeeded. בשניהם מפיקים רק כשהשדה payment_status שווה paid.

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

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

איך מופיעים הפריטים על החשבונית?

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

התשלום בדולרים. מה מופק?

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

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

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

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

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

מפתח Sandbox בחינם