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

חשבונית אוטומטית מטרנזילה: מדף התשלום למסמך

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

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

מגדירים כתובת notify

בדף התשלום של טרנזילה (iframe או הפניה) מעבירים את הפרמטר notify_url_address, או מגדירים את הכתובת בהגדרות דף התשלום במסוף. טרנזילה שולחת אליה את נתוני העסקה כפי שבוצעה, במקביל להחזרת הלקוח לאתר. את שיטת ההחזרה מגדירים כ-POST.

ההודעה לא חתומה, ולכן הכתובת עצמה נושאת סוד:

notify_url_address
https://your-server.example.com/hooks/tranzila?key=LONG_RANDOM_SECRET

מאמתים עסקה שלא חתומה

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

tranzila.mjsGitHub
// The notify POST carries no signature. Two things stand in for one:
// 1. The notify address is yours to choose, so it carries a secret only you and the terminal
//    know: https://shop.example.com/hooks/tranzila?key=<TRANZILA_WEBHOOK_SECRET>
// 2. The transaction is read back from Tranzila by its index before anything is issued
//    (confirm(), below), when the API keys are set.
export async function verify({ query, rawBody, secret }) {
  if (!sameSecret(query.get('key'), secret)) return false
  const { TRANZILA_APP_KEY: appKey, TRANZILA_SECRET: apiSecret, TRANZILA_TERMINAL: terminal } = process.env
  if (!appKey) return true
  const f = formFields(rawBody)
  return confirm({ terminal: terminal || f.supplier, index: f.index, amount: toMinor(f.sum), appKey, apiSecret })
}

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

tranzila.mjsGitHub
// POST https://api.tranzila.com/v1/transactions answers the transaction as Tranzila holds it.
// It is approved when it carries this index, response code 000 and the amount that was notified.
export async function confirm({ terminal, index, amount, appKey, apiSecret, baseUrl = process.env.TRANZILA_API_URL || 'https://api.tranzila.com' }) {
  const time = String(Math.floor(Date.now() / 1000))
  const nonce = crypto.randomBytes(20).toString('hex')
  const res = await fetch(`${baseUrl}/v1/transactions`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-tranzila-api-app-key': appKey,
      'X-tranzila-api-request-time': time,
      'X-tranzila-api-nonce': nonce,
      'X-tranzila-api-access-token': crypto.createHmac('sha256', apiSecret + time + nonce).update(appKey).digest('hex'),
    },
    body: JSON.stringify({ terminal_name: terminal, transaction_index: Number(index) }),
  })
  if (!res.ok) return false
  const tx = find(await res.json(), String(index))
  if (!tx) return false
  const code = String(tx.processor_response_code ?? tx.Response ?? tx.response_code ?? '')
  // The report states the amount in agorot ("12900"), the notify in shekels ("129.00").
  return code === '000' && Number(tx.amount) === amount
}
// The shape of this answer is not published, so the transaction is found wherever it sits.
function find(node, index, depth = 0) {
  if (!node || typeof node !== 'object' || depth > 6) return null
  const own = node.index ?? node.transaction_index ?? node.transaction_id
  if (own != null && String(own) === index) return node
  for (const v of Object.values(node)) {
    const hit = find(v, index, depth + 1)
    if (hit) return hit
  }
  return null
}

מההודעה לתשלום

הגוף הוא טופס רגיל. השדות העיקריים:

שדהמה יש בו
Response000 לעסקה מאושרת. כל ערך אחר הוא סירוב.
indexמספר העסקה במסוף. יחד עם שם המסוף, זה מפתח ה-idempotency.
sumהסכום שחויב, עם נקודה עשרונית.
currency1 שקל, 2 דולר, 978 אירו, 826 ליש״ט.
contact, company, email, phoneפרטי הלקוח, כפי ששלחתם אותם לדף התשלום.
pdescתיאור העסקה. מודפס כשורת המסמך.
tranzila.mjsGitHub
// The body is application/x-www-form-urlencoded. `sum` is a decimal amount in the
// transaction's currency, and Response "000" is an approved transaction.
const CURRENCIES = { 1: 'ILS', 2: 'USD', 978: 'EUR', 826: 'GBP' }

export function parse({ rawBody }) {
  const f = formFields(rawBody)
  if (f.Response !== '000' || !f.index) return null
  return {
    payment: {
      source: name,
      id: `${f.supplier || 'terminal'}-${f.index}`, // an index counts per terminal
      amount: toMinor(f.sum),
      currency: CURRENCIES[f.currency] || 'ILS',
      method: 'card',
      description: f.pdesc || `עסקה ${f.index}`,
      // Only what your own page sent in: Tranzila echoes the fields it was given.
      customer: { name: f.company || f.contact, email: f.email, phone: f.phone },
      items: [],
    },
  }
}

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

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

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
}

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

כדאי לדעת

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

שאלות נפוצות

מה זו כתובת notify בטרנזילה?

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

למה לא להפיק את החשבונית בדף התודה?

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

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

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

פרטי הלקוח מגיעים בהודעה?

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

מה עושים בזיכוי עסקה?

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

מה עם תשלומים ב-Bit דרך טרנזילה?

גם ל-Bit יש כתובת הודעה, והיא נשלחת בין שהתשלום הצליח ובין שנכשל. בודקים את קוד התשובה לפני שמפיקים.

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

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

מפתח Sandbox בחינם