Skip to main content

Shillinq AP integration — developer architecture

This page documents the event-driven flow that publishes approved pipelinq expenses to Shillinq as AP vouchers, and the contracts a Shillinq consumer (or any other downstream subscriber) MUST honour.

Spec

  • OpenSpec change: openspec/changes/pipelinq-expense-to-shillinq-ap/
  • Requirements: REQ-AP-001 through REQ-AP-007
  • Admin guide: docs/Integrations/shillinq-ap-setup.md

Components

ComponentRole
lib/Listener/ExpenseApprovalListener.phpListens to OR ObjectCreatedEvent / ObjectUpdatedEvent, detects expense
objects transitioning to status="approved", persists apSyncStatus="pending",
re-emits a domain ExpenseApprovedEvent, calls ShillinqApService::dispatch....
lib/Event/ExpenseApprovedEvent.phpDomain event the listener fans out so downstream consumers can subscribe
without coupling to OpenRegister's internal events.
lib/Service/ShillinqApService.phpBuilds the CloudEvents 1.0 envelope, delegates to OR WebhookService,
exposes shouldDispatch() / dispatchApEvent() / now().
lib/Service/ApSyncNotifier.phpWraps NC notification dispatch for the "AP sync failed" admin notification.
lib/Controller/ShillinqApController.php#[AuthorizedAdminSetting] retry endpoint reachable from the expense detail UI.
lib/Settings/register.d/30-expense-shillinq-ap.jsonADR-037 register fragment that extends expense with apSyncStatus /
apSyncedAt and seeds the 5 AP-status demo objects.
src/views/expenses/ExpenseList.vueRenders the apSyncStatus column with color-coded badges (REQ-AP-005).
src/components/ExpenseShillinqApCard.vueExpense-detail card with status + manual-retry button (REQ-AP-006).

Event flow

OpenRegister save(expense)


ObjectCreatedEvent / ObjectUpdatedEvent

▼ (registered in lib/AppInfo/Application.php)
ExpenseApprovalListener::handle()

│ guards:
│ - is expense schema?
│ - status === "approved"?
│ - apSyncStatus !== "synced" (idempotent — REQ-AP-002 Scenario 5)
│ - apService->shouldDispatch() (REQ-AP-002 Scenario 6)


IEventDispatcher::dispatchTyped(ExpenseApprovedEvent)


ObjectService::saveObject({...expense, apSyncStatus: "pending"})


ShillinqApService::dispatchApEvent(expense, approvedBy, approvedAt)

│ - builds CloudEvents 1.0 envelope
│ - calls OR WebhookService::dispatchEvent($e, EVENT_EXPENSE_APPROVED, payload)

├── on success ─▶ ObjectService::saveObject({apSyncStatus: "synced", apSyncedAt: now()})

└── on failure ─▶ ObjectService::saveObject({apSyncStatus: "failed"})
ApSyncNotifier::notifyFailure(title, uuid)

The listener catches every Throwable and only logs — the originating expense write MUST never fail because the AP integration is unhealthy (REQ-AP-002, "MUST NOT block other handlers").

CloudEvents 1.0 payload contract

ShillinqApService::EVENT_EXPENSE_APPROVED is nl.conduction.pipelinq.expense.approved. Source is /apps/pipelinq/expenses. The envelope shape is asserted by ShillinqApServiceTest and ExpenseApSyncTest.

{
"specversion": "1.0",
"type": "nl.conduction.pipelinq.expense.approved",
"source": "/apps/pipelinq/expenses",
"id": "<expense-uuid>",
"time": "<ISO 8601 UTC>",
"datacontenttype": "application/json",
"data": {
"expenseId": "<expense-uuid>",
"amount": 185.50, // number, in `data.currency`
"currency": "EUR", // ISO 4217
"categoryId":"accommodation", // free-form category slug
"clientId": "<client-uuid>",
"projectId": "<project-uuid|null>",
"billable": true, // boolean
"approvedBy":"<approver-user-id>",
"approvedAt":"<ISO 8601 UTC>"
}
}

id and data.expenseId are intentionally the same pipelinq expense UUID so Shillinq can use the CloudEvents id as the natural idempotency key.

Subscribing in-process to ExpenseApprovedEvent

Other pipelinq subsystems (reporting, audit, automation) MAY subscribe to the domain event directly without going through the AP webhook:

use OCA\Pipelinq\Event\ExpenseApprovedEvent;

$container->get(\OCP\EventDispatcher\IEventDispatcher::class)
->addListener(
ExpenseApprovedEvent::class,
function (ExpenseApprovedEvent $event): void {
$uuid = $event->getExpenseUuid();
$expense = $event->getExpense();
$approvedBy = $event->getApprovedBy();
$approvedAt = $event->getApprovedAt();
// ...do work...
},
);

The event carries the full expense payload so subscribers don't need to re-fetch it from OpenRegister.

Manual retry endpoint

POST /apps/pipelinq/api/v1/expenses/{id}/shillinq-ap/retry is gated with #[AuthorizedAdminSetting(Application::APP_ID)] and MUST only be called for expenses whose apSyncStatus = "failed" (REQ-AP-003 Scenario 11). The controller refuses retries for any other state to prevent duplicate vouchers.

Response shapes:

  • 200 { "apSyncStatus": "synced", "apSyncedAt": "<iso>" } on success.
  • 502 { "apSyncStatus": "failed", "error": "AP dispatch failed." } when the webhook rejects the retry.
  • 400 { "error": "Only failed AP syncs can be retried.", ... } when the expense is not in failed state.
  • 400 { "error": "Shillinq AP integration is not configured." } when the webhook URL is empty.
  • 404 { "error": "Expense not found." } when the UUID does not resolve.

Test layers

LayerFileCoverage
Unittests/Unit/Listener/ExpenseApprovalListenerTest.phpListener guards, idempotency, success/failure persistence.
Unittests/Unit/Service/ShillinqApServiceTest.phpshouldDispatch(), CloudEvents payload shape, dispatch path.
Integrationtests/Integration/ExpenseApSyncTest.phpFull approval-to-sync flow with the real AP service wired.
E2E (Gate-19)tests/e2e/spec-coverage/expense-shillinq-ap.spec.tsAdmin settings field, expense list mount, detail-route mount.

Backend dispatch is not exercised end-to-end through the browser: that would require a live Shillinq consumer at the configured webhook URL.