School contributions API
Endpoint: POST /index.php/apps/shillinq/api/contributions/raise
Spec: extracurricular-fee-to-shillinq, REQ-SCON-001 to REQ-SCON-010. The full contract is in contract.md.
Consumers: learniq (fee items), portaliq (activities).
Purpose
Bill a set of guardians for one school contribution in one call: the ouderbijdrage, the overblijfbijdrage, a schoolreisje, a club. Each guardian gets one issued invoice and one payment request. The request names the fee item or activity in the owning app and the child it is for, so that app can find the payment state later.
Authentication
A Nextcloud session whose user carries the payment.request action, or an
admin. Map the action to groups in the paymentActionGroups app config:
occ config:app:set shillinq paymentActionGroups --value '{"payment.request":["school-coordinators"]}'
An app that already runs inside the request can call
OCA\Shillinq\Service\ContributionRaiseService::raise() with the same array.
Guard the call with class_exists(); shillinq is optional.
Request
{
"chargeable": { "app": "learniq", "type": "fee-item", "register": "learniq", "schema": "FeeItem", "id": "<fee-item-uuid>" },
"kind": "parental-contribution",
"description": "Ouderbijdrage 2026-2027",
"amount": 60.0,
"voluntary": true,
"administrationId": "adm-school-1",
"recipients": [
{
"debtor": { "portalSubjectRef": "<guardian-subject-ref>", "name": "J. de Vries", "email": "j.devries@example.nl" },
"beneficiary": { "type": "learner", "id": "<learner-id>" }
}
]
}
kindis one ofparental-contribution,lunch-supervision,school-trip,activity,other.- Send at most 200 recipients per call. Send the rest in the next call.
- A recipient's own
amountoverrides the charge, for a reduction. - Leave out
beneficiaryto bill per household. - Optional:
currency(EUR),invoiceDate(today),dueDate(30 days later),revenueAccount,language(nl).
Response
200 with one result per recipient:
{
"batchId": "ctb-20261001-1a2b3c4d",
"raised": 1,
"skipped": 0,
"failed": 0,
"results": [
{ "index": 0, "status": "raised", "invoiceId": "<uuid>", "invoiceNumber": "CTB-2026-1A2B3C4D-0001", "paymentRequestId": "<uuid>", "customerMasterId": "<uuid>", "portalLinked": true }
]
}
skippedmeans this child already has a request on this fee item. The result names it, so a retried call bills nobody twice.failedcarries the reason. The other recipients are still billed.400for a call that cannot be raised as a whole,401without a session,403without the action.
Voluntary contributions
With voluntary: true the invoice says the contribution is voluntary and that
the child takes part either way. The guardian gets one reminder at most,
without collection costs or interest, and the invoice never goes to a
collection agency.
That one reminder is its own letter, not the ladder's first stage. It says
again that the contribution is voluntary and that the child takes part. It
names no payment term, no costs, no interest and no bank account. It goes out
in the invoice's language: pass language (nl or en) in the raise call.
Any other language gets the Dutch letter. The templates are
tpl-dunning-voluntary-contribution-nl and -en in
lib/Settings/docudesk-templates.json.
"I will not pay"
A guardian can refuse a voluntary contribution. The reminder tells them how:
"I will not pay" in the parent portal. The invoice then becomes declined,
contribution.declinedAt records when, and every pending payment request on it
becomes voided. A declined invoice is never reminded and is not overdue.
Portaliq forwards the choice server to server:
POST /apps/shillinq/api/portal/contributions/decline
X-Portal-Subject: <signed assertion>
{"invoiceId": "<invoice uuid>"}
200 {"status": "declined"}, also for a second call on the same invoice.401without a valid assertion.403for anything but the guardian's own voluntary contribution inissuedoroverdue. One answer for every reason.502when OpenRegister fails.
A refusal that arrives by mail or phone is recorded by a bookkeeper with the
decline transition on the invoice. It only accepts a voluntary contribution.
An owning app (learniq, portaliq) sees the refusal as a voided payment request
and an invoice in declined.
Knowing when a payment is in
Shillinq writes settledAt and settledVia on the payment request the first
time it counts as paid, and never moves them. Listen for that field appearing:
- inside Nextcloud, on OpenRegister's
ObjectUpdatedEventfor registershillinq, schemaPaymentRequest; - outside Nextcloud, through an integriq event subscription on
com.nextcloud.openregister.object.updated.
The listener code and the subscription filter are in contract.md.
Where the guardian pays
In the portal, under their contributions, with the pay button. Guardians sign in
with audience parent. Without a portal account, send the payment link from the
payment request panel.