מתשלום למסמך: חשבונית אוטומטית על כל תשלום
לקוח שילם, ועכשיו העסק חייב לו חשבונית. זה החיבור הנפוץ ביותר ל-API של חשבוניות, והוא נראה אותו דבר בכל מקור תשלום: מאמתים את ההתראה, מפיקים מסמך אחד, ומטפלים בהחזר. כאן התבנית, ומתחתיה עמוד לכל מקור.
עמוד לכל מקור תשלום
לכל מקור יש עמוד עם ההגדרות שלו ועם הקוד: איפה מגדירים את ההתראה, איך מאמתים אותה, ואיך שדות התשלום הופכים למסמך.
Stripe
webhook של Checkout הופך לחשבונית מס קבלה, והחזר הופך לזיכוי.WooCommerce
ה-webhook של החנות הופך כל הזמנה ששולמה לחשבונית מס קבלה, עם הפריטים והמשלוח.Shopify
ה-webhook orders/paid הופך לחשבונית מס קבלה, ו-refunds/create לזיכוי.טרנזילה
כתובת ה-notify של דף התשלום הופכת כל עסקה מאושרת לחשבונית מס קבלה.קארדקום
ה-WebHookUrl של Low Profile הופך כל עסקה מאושרת לחשבונית מס קבלה.PayPlus
ה-callback של דף התשלום הופך לחשבונית מס קבלה, עם הפריטים.Grow (משולם)
עדכון השרת של דף התשלום הופך כל תשלום למסמך, ו-Bit מודפס כ-Bit.כל הקוד בעמודים האלה הוא דוגמה אחת שאפשר להריץ, payment-to-invoice: שרת Node קטן בלי תלויות, עם נתיב לכל מקור ועם בדיקות אוטומטיות.
שלושה צעדים, בכל מקור
כל ההתראות נכנסות לאותה פונקציה. היא עושה שלושה דברים, בסדר הזה:
export async function handle(source, { headers, rawBody, query = new URLSearchParams() }) {
const secret = process.env[`${source.name.toUpperCase()}_WEBHOOK_SECRET`]
// 1. Is it really from them? Checked on the raw bytes, before anything is parsed. A source
// that signs nothing is read back from the provider instead, and answers the body to trust.
const trusted = await source.verify({ headers, rawBody, query, secret })
if (!trusted) return 401
// 2. What happened? null is an event this server does not act on, answered 200 so the
// sender stops retrying it.
const event = source.parse({ headers, rawBody: typeof trusted === 'string' ? trusted : rawBody })
if (!event) return 200
// 3. A payment becomes a document; a refund becomes the credit note of that document.
// Both calls are idempotent, so a notification that repeats what is known changes nothing.
const ctx = { apiKey, businessId }
if (event.payment) {
const payment = event.payment
if (source.lineItems && payment.sessionId) payment.items = await source.lineItems(payment.sessionId, process.env.STRIPE_SECRET_KEY)
const document = await issueForPayment(payment, ctx)
documents.set(`${payment.source}:${payment.id}`, document.id)
console.log(`${payment.source}:${payment.id} -> document ${document.docNumber}`)
}
for (const refund of event.refunds || (event.refund ? [event.refund] : [])) {
const documentId = documents.get(`${refund.source}:${refund.paymentId}`)
if (!documentId) continue // a payment this server never invoiced
const credit = await creditForRefund(refund, { ...ctx, documentId })
console.log(`${refund.source}:refund:${refund.id} -> credit note ${credit.docNumber}`)
}
return 200
}- מאמתים שההתראה אמיתית
כתובת של webhook היא כתובת ציבורית, וכל אחד יכול לשלוח אליה ״שולם״. האימות נעשה על הבייטים כמו שהגיעו, לפני שקוראים מהם משהו. ספק שחותם על ההתראה נבדק לפי החתימה. ספק שלא חותם נבדק בסוד שנמצא בכתובת, ובקריאה חוזרת אליו.
- מבינים מה קרה
כל מקור מתורגם לאותו אובייקט קטן: מזהה התשלום, הסכום באגורות, המטבע, הלקוח והפריטים. אירוע שלא מעניין אותנו נענה ב-200, כדי שהספק לא ינסה שוב.
- מפיקים
תשלום הופך למסמך, והחזר הופך לזיכוי של אותו מסמך.
ההפקה: מסמך אחד לכל תשלום
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
}איזה מסמך
הכסף כבר התקבל, ולכן המסמך הוא חשבונית מס קבלה (320): המכירה והתשלום שלה יחד. עוסק פטור אינו רשאי להוציא חשבונית מס, והמסמך שלו הוא קבלה (400). הקוד קורא את סוג העוסק של העסק ובוחר, כך שאותו שרת משרת את שניהם.
פעם אחת
ספקי תשלום שולחים התראה שוב כשלא קיבלו תשובה, ולפעמים גם כשקיבלו. לכן מפתח ה-idempotency הוא המזהה שהספק עצמו נתן לתשלום. הקריאה השנייה מחזירה את המסמך הקיים, והקוד רואה שהוא כבר הופק ועוצר. אין צורך בטבלת ״התראות שכבר טופלו״.
הסכום ששולם הוא העובדה
הלקוח שילם מחיר שכולל מע״מ. ב-priceMode: "gross" שולחים את המחיר הזה כמו שהוא, סכום המסמך יוצא בדיוק מה ששולם, והמע״מ מחושב מתוכו. הפריטים מודפסים רק כשהסכום שלהם שווה לסכום שחויב. קופון, משלוח או עיגול בצד של החנות יוצרים לפעמים פער של אגורה, ומסמך שלא מסכים עם התשלום גרוע ממסמך עם שורה אחת.
סכומים באגורות
ה-API מקבל מספרים שלמים באגורות. ספקים ששולחים מחיר כמחרוזת עשרונית (״349.90״) מומרים פעם אחת, בכניסה, בלי לעבור דרך מספר עשרוני:
toMinor('349.90') // 34990
toMinor('0.07') // 7
Math.round(349.9 * 100) // works here, and fails on the day a product lands on 34989.999החזר כספי הוא זיכוי
מסמך שהופק לא נערך ולא נמחק. כשכסף חוזר ללקוח, מפיקים חשבונית זיכוי שמפנה למסמך המקורי. בלי סכום, הזיכוי הוא על כל המסמך. עם סכום, על החלק שהוחזר.
// 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
}כדי להפיק זיכוי צריך לדעת איזה מסמך הפיק התשלום המקורי. שומרים את מזהה המסמך על ההזמנה אצלכם ברגע ההפקה. הדוגמה שומרת אותו בזיכרון, וזה מספיק רק כדי להראות את הרעיון.
לא כל ספק שולח התראה על החזר. Stripe, Shopify ו-WooCommerce שולחים. אצל רוב ספקי הסליקה הישראליים החזר הוא קריאת API שאתם מבצעים, ולכן את הזיכוי מפיקים באותו מקום בקוד שבו מבצעים את ההחזר.
מה עונים לספק
| מה קרה | מה עונים | למה |
|---|---|---|
| המסמך הופק, או שכבר היה קיים | 200 | הספק מפסיק לשלוח. |
| אירוע שלא נוגע לנו | 200 | אין מה לנסות שוב. |
| האימות נכשל | 401 | זו לא התראה של הספק. |
| BillOS סירבה, וניסיון חוזר לא ישנה | 200, ורושמים ביומן | סכום שגוי או עסק סגור לא יסתדרו מעצמם. קוראים את reason ואת hint ומטפלים. |
| תקלה זמנית | 500 | הספק ינסה שוב, והמפתח מבטיח שלא ייווצר מסמך כפול. |
רוב הספקים מחכים שניות בודדות לתשובה. אם ההפקה אצלכם איטית, עונים מיד ומפיקים ברקע.
לקוח עסקי ומספר הקצאה
כשהלקוח הוא עסק, שולחים את מספר העוסק שלו בשדה taxId. אם החשבונית עולה על 5,000 ₪ לפני מע״מ, היא צריכה מספר הקצאה. לעסק שמחובר לרשות המסים המספר מתבקש בתוך ההפקה, ואין מה לשנות בקוד שכאן. הפרטים בעמוד מספר הקצאה ב-API.
מנסים
git clone https://github.com/billos-dev/billos && cd billos/examples/payment-to-invoice
npx billos sandbox # a free sandbox key: approve it once in the browser
npx billos seed # a demo business to issue from
cp .env.example .env # the key, the business id and your source's secret
node --env-file=.env server.mjs
node --test # every source, end to end, with no networkשאלות נפוצות
איזה מסמך מפיקים על תשלום שהתקבל?
חשבונית מס קבלה, סוג 320: המכירה והתשלום שלה במסמך אחד. עוסק פטור אינו רשאי להוציא חשבונית מס, ולכן מוציא קבלה, סוג 400. הקוד בודק את סוג העוסק של העסק ובוחר.
מה קורה כשאותה התראה מגיעה פעמיים?
כלום. הטיוטה נוצרת עם מפתח idempotency שהוא מזהה התשלום אצל הספק, ולכן הקריאה השנייה מחזירה את המסמך שכבר קיים, עם אותו מספר.
המחיר בחנות כולל מע״מ. איך שולחים אותו?
כמו שהוא, עם priceMode: "gross". סכום המסמך יהיה בדיוק מה שהלקוח שילם, והמע״מ מחושב מתוכו.
מה עושים כשהלקוח מקבל החזר?
מפיקים חשבונית זיכוי למסמך המקורי, בקריאה אחת, על כל הסכום או על חלקו. המסמך המקורי נשאר בספרים כמו שהוא.
צריך שרת משלי בשביל זה?
צריך כתובת שמקבלת את ההתראה: שרת קטן, פונקציית ענן, או נתיב באפליקציה שכבר יש לכם. מי שלא רוצה לכתוב קוד יכול להשתמש באפליקציות של BillOS ל-Zapier ול-Make.
מה אם ספק הסליקה כבר מפיק חשבונית בעצמו?
אז לא מפיקים שנייה. חלק מהספקים, כמו CardCom ו-PayPlus, יכולים להפיק מסמך בעצמם, וההתראה שלהם אומרת אם זה קרה. הקוד בודק את זה ומדלג.
המסמך הראשון, עוד היום
מפתח Sandbox בחינם, בלי כרטיס אשראי. הקוד שעובד מול הבדיקות עובד מול live בלי שינוי.
מפתח Sandbox בחינם

