Recipes

Accounting export: QuickBooks, Xero, Tally

Post fee income to the school's books: a daily journal from GET /v1/receipts, or one entry per payment from the fee.payment_received webhook.

Goal

Every fee receipt becomes a journal entry: money into the cash, bank or wallet account it was received in, against fee income (and late-fine income). Run it as a daily export file the accountant imports, or post each payment as it happens.

Mapping receipts to accounts

A receipt has an amount (late fines included), its lateFine part, and the method it was paid by. One receipt becomes two or three journal lines:

  • Debit the account for the method (Cash in Hand for cash, the school's bank account for ibft and bank-deposit, a wallet clearing account for jazzcash / easypaisa): the full amount.
  • Credit Tuition Fee Income: the amount less the late fine.
  • Credit Late Fine Income: the late fine, when there is one.

Use the receipt number as the journal reference so the entry is easy to trace and never imported twice. Cancelled receipts come with cancelled: true: skip them (or reverse them if they were exported before).

A daily export

Run once a day, after the school closes, for the day before. It pages through GET /v1/receipts?from=&to= and writes a CSV most accounting tools can import (QuickBooks and Xero journal import, or Tally through a CSV-to-XML import tool).

// journal.mjs: yesterday's fee receipts as journal lines, for QuickBooks, Xero or Tally import.
import fs from "node:fs/promises"

const headers = { Authorization: `Bearer ${process.env.SCHOOL360_KEY}` }
// School360 payment method → the ledger account the money went into
const DEBIT = { cash: "Cash in Hand", "bank-deposit": "HBL Current A/C", ibft: "HBL Current A/C", jazzcash: "JazzCash Wallet", easypaisa: "Easypaisa Wallet" }
const FEES = "Tuition Fee Income"
const FINES = "Late Fine Income"

const day = new Date(Date.now() - 86_400_000).toLocaleDateString("en-CA", { timeZone: "Asia/Karachi" }) // YYYY-MM-DD
const rows = [["Date", "Journal No", "Account", "Debit", "Credit", "Memo"]]
let cursor = null
do {
  const q = new URLSearchParams({ from: day, to: day, limit: "200", ...(cursor ? { cursor } : {}) })
  const res = await fetch(`https://api.school360.cloud/v1/receipts?${q}`, { headers })
  const page = await res.json()
  if (!res.ok) throw new Error(`${res.status} ${page.error.code}`)
  for (const r of page.data) {
    if (r.cancelled) continue
    const memo = [r.number, r.reference].filter(Boolean).join(" ")
    rows.push([r.receivedOn, r.number, DEBIT[r.method] ?? "Suspense", r.amount, "", memo])
    rows.push([r.receivedOn, r.number, FEES, "", r.amount - r.lateFine, memo])
    if (r.lateFine > 0) rows.push([r.receivedOn, r.number, FINES, "", r.lateFine, memo])
  }
  cursor = page.hasMore ? page.nextCursor : null
} while (cursor)

const csv = rows.map((r) => r.map((v) => (/[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : v)).join(",")).join("\n")
await fs.writeFile(`journal-${day}.csv`, csv + "\n")

The file

Two receipts, one with a late fine:

CSV
Date,Journal No,Account,Debit,Credit,Memo
2026-10-07,RCT-2026-00981,HBL Current A/C,8700,,RCT-2026-00981 FT26100812345
2026-10-07,RCT-2026-00981,Tuition Fee Income,,8500,RCT-2026-00981 FT26100812345
2026-10-07,RCT-2026-00981,Late Fine Income,,200,RCT-2026-00981 FT26100812345
2026-10-07,RCT-2026-00982,Cash in Hand,4200,,RCT-2026-00982
2026-10-07,RCT-2026-00982,Tuition Fee Income,,4200,RCT-2026-00982

As it happens: webhooks

For books that update straight away, subscribe an endpoint to fee.payment_received. Its body is the receipt with the vouchers and months it paid and the family, so each line can carry the student and month too.

fee.payment_received · example body
{
  "id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
  "event": "fee.payment_received",
  "createdAt": "2026-10-08T05:15:03.412Z",
  "school": {
    "code": "SCH00001",
    "name": "Axiom School"
  },
  "data": {
    "id": 981,
    "number": "RCT-2026-00981",
    "amount": 8500,
    "lateFine": 0,
    "familyId": 233,
    "campusId": 1,
    "method": "ibft",
    "reference": "FT26100812345",
    "receivedOn": "2026-10-08",
    "createdAt": "2026-10-08T05:15:02.000Z",
    "lines": [
      {
        "voucherId": 5120,
        "voucherNumber": "FV-2026-10-00123",
        "studentId": 412,
        "month": "2026-10",
        "amount": 8500,
        "fine": 0
      }
    ],
    "family": {
      "id": 233,
      "code": "FAM-00233",
      "primaryContact": "father",
      "father": {
        "name": "Imran Siddiqui",
        "phone": "0300-1234567"
      },
      "mother": {
        "name": "Sana Imran",
        "phone": "0321-7654321"
      },
      "guardian": null,
      "phone": "0300-1234567"
    }
  }
}
Node.js
// The same journal lines as each payment happens: the fee.payment_received webhook
// (check the signature first, as in the Webhooks guide)
function journalLines(event) {
  const r = event.data // the receipt, with its lines (vouchers and months paid) and the family
  const memo = `${r.number} ${r.family?.code ?? ""} ${r.lines.map((l) => l.voucherNumber).join(" ")}`.trim()
  return [
    { date: r.receivedOn, ref: r.number, account: DEBIT[r.method] ?? "Suspense", debit: r.amount, memo },
    { date: r.receivedOn, ref: r.number, account: "Tuition Fee Income", credit: r.amount - r.lateFine, memo },
    ...(r.lateFine > 0 ? [{ date: r.receivedOn, ref: r.number, account: "Late Fine Income", credit: r.lateFine, memo }] : []),
  ]
}
// Post them with your accounting software's API, keyed by r.number so a repeated event is skipped

Pitfalls

  • Use one source per entry: either the daily pull or the webhook, not both, or every receipt is booked twice.
  • Dates: receivedOn is the school's date the receipt was made. Run the daily job after midnight in the school's time zone.
  • Refunds and cancellations change receipts after the day: re-read the last few days (or poll GET /changes for receipts) before a month is closed.
  • A read-only key with fees: read is all this needs.
Next recipeZapier, Make and Google Sheets