danwel - Integrations

Provider interfaces, OAuth flows, and data flow documentation for external integrations.

---

Table of Contents



1. [Integration System](#integration-system)
2. [Data Ownership Strategy](#data-ownership-strategy)
3. [Google Calendar Integration](#google-calendar-integration)
4. [Moneybird Integration](#moneybird-integration)
5. [Adding New Providers](#adding-new-providers)

---

Integration System



Multi-Calendar Support



interface CalendarProviderInterface
{
// OAuth
public function getAuthorizationUrl(string $state): string;
public function handleCallback(string $code): TokenData;
public function refreshToken(Integration $integration): TokenData;
public function disconnect(Integration $integration): void;

// Calendar discovery (for multi-calendar)
public function getAvailableCalendars(Integration $integration): Collection;

// Events (calendar-aware)
public function getEvents(Integration $integration, Calendar $calendar, DateRange $range): Collection;
public function createEvent(Integration $integration, Calendar $calendar, EventData $event): EventData;
public function updateEvent(Integration $integration, Calendar $calendar, string $eventId, EventData $event): EventData;
public function deleteEvent(Integration $integration, Calendar $calendar, string $eventId): void;

// Provider info
public function getProviderName(): string;
public function getProviderKey(): string;
public function getRequiredScopes(): array;
}


Integration Manager



class IntegrationManager
{
public function __construct(
private readonly TenantContext $tenantContext,
private readonly array $calendarProviders = [],
private readonly array $invoicingProviders = []
) {}

// Get all active integrations for current organization
public function getActiveCalendarIntegrations(): Collection
{
return Integration::where('provider_type', 'calendar')
->where('is_active', true)
->get();
}

// Get enabled calendars across all integrations
public function getEnabledCalendars(): Collection
{
return Calendar::where('is_enabled', true)
->with('integration')
->get();
}

// Sync events from all enabled calendars
public function syncAllCalendars(DateRange $range): void
{
foreach ($this->getEnabledCalendars() as $calendar) {
$provider = $this->calendarProvider($calendar->integration->provider);
$events = $provider->getEvents($calendar->integration, $calendar, $range);
// Process events...
}
}
}

---

Data Ownership Strategy



Source of Truth: Local Database

The danwel database is the **source of truth** for time blocks. External services (Google Calendar, Moneybird) are mirrors that are kept in sync.

┌─────────────────────────────────────────────────────────────────┐
│ danwel Database │
│ (Source of Truth) │
└─────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Google │ │ Moneybird │
│ Calendar │ │ │
│ (Mirror) │ │ (Mirror) │
└─────────────┘ └─────────────┘


Benefits



| Benefit | Description |
|---------|-------------|
| **Offline resilience** | App works even if Google/Moneybird is down |
| **Fast UI** | Load from local DB instantly, sync in background |
| **Data ownership** | Full control over your data |
| **Conflict resolution** | Detect and handle sync conflicts |
| **Audit trail** | Track changes, support undo |
| **Reporting** | Query your own data (hours per client, trends) |
| **Provider flexibility** | Switch providers without losing data |

Sync Flow



**On Create/Update/Delete:**
1. Save to database immediately (UI responds instantly)
2. Queue background job to sync to external services
3. If sync fails → Mark sync_status: pending, retry with backoff
4. User sees sync status indicator (✓ synced, ⟳ pending, ⚠ error)

**Periodic Sync (Inbound):**
1. Scheduled job fetches changes from Google Calendar
2. Detect events created/modified outside danwel
3. Import or flag for user review
4. Handle conflicts (e.g., same event modified in both places)

Sync Status States



pending   → Not yet synced to external service
syncing → Currently being synced
synced → Successfully mirrored to external services
error → Sync failed (will retry)
conflict → External change detected, needs resolution
disabled → User disabled sync for this block


Conflict Resolution Strategy



| Scenario | Resolution |
|----------|------------|
| Block edited in danwel | Push to external services |
| Event edited in Google Calendar | Pull changes, update local (or flag if local was also changed) |
| Event deleted in Google Calendar | Mark local as deleted or flag for user |
| Sync error (API down) | Retry with exponential backoff, notify user after N failures |

Queue Jobs



// Sync single time block to external services
SyncTimeBlockJob::dispatch($timeBlock);

// Periodic: Pull changes from all calendars
PullCalendarChangesJob::dispatch($integration);

// Retry failed syncs
RetryPendingSyncsJob::dispatch();

---

Google Calendar Integration



OAuth Flow



**Scopes Requested:**
https://www.googleapis.com/auth/calendar.events


**Token Exchange Response:**
``json
{
"access_token": "ya29.xxx",
"expires_in": 3599,
"refresh_token": "1//xxx",
"scope": "https://www.googleapis.com/auth/calendar.events",
"token_type": "Bearer"
}

**Additional Data Fetched on Connect:**

GET https://www.googleapis.com/calendar/v3/users/me/settings/timezone
→ { "value": "Europe/Amsterdam" }

**Stored Token Data:**
json
{
"access_token": "ya29.xxx",
"expires_in": 3599,
"refresh_token": "1//xxx",
"expires_at": 1702300000,
"timezone": "Europe/Amsterdam"
}

Events API



**Fetch Events (Read):**

GET https://www.googleapis.com/calendar/v3/calendars/primary/events
?timeMin=2024-01-08T00:00:00+01:00
&timeMax=2024-01-14T00:00:00+01:00
&singleEvents=true
&orderBy=startTime

**Event Structure (from Google):**
json
{
"id": "abc123xyz",
"summary": "Meeting with Client",
"description": "Project: 123456\nClient: 789012\n— Created with danwel",
"colorId": "10",
"start": {
"dateTime": "2024-01-08T09:00:00+01:00",
"timeZone": "Europe/Amsterdam"
},
"end": {
"dateTime": "2024-01-08T10:00:00+01:00",
"timeZone": "Europe/Amsterdam"
},
"extendedProperties": {
"private": {
"project": "123456",
"client": "789012",
"weekplanner": "true",
"timeEntryId": "456789"
}
}
}

**Create Event (Write):**

POST https://www.googleapis.com/calendar/v3/calendars/primary/events

{
"summary": "Client Work",
"description": "Project: 123456\nClient: 789012\n— Created with danwel",
"colorId": "10",
"start": {
"dateTime": "2024-01-08T09:00:00",
"timeZone": "Europe/Amsterdam"
},
"end": {
"dateTime": "2024-01-08T10:00:00",
"timeZone": "Europe/Amsterdam"
},
"extendedProperties": {
"private": {
"project": "123456",
"client": "789012",
"weekplanner": "true",
"timeEntryId": "456789"
}
}
}

**Fields We Use from Google Calendar:**

| Field | Purpose |
|-------|---------|
|
id | Unique event identifier |
|
summary | Event title |
|
start.dateTime | Start time with timezone |
|
end.dateTime | End time with timezone |
|
colorId | Google Calendar color (1-11) |
|
extendedProperties.private.project | Moneybird project ID |
|
extendedProperties.private.client | Moneybird contact ID |
|
extendedProperties.private.weekplanner | Flag: "true" if created by us |
|
extendedProperties.private.timeEntryId | Moneybird time entry ID |

---

Moneybird Integration



OAuth Flow



**Scopes Requested:**

time_entries settings

**Token Exchange Response:**
json
{
"access_token": "xxx",
"token_type": "bearer",
"expires_in": 7200,
"refresh_token": "xxx",
"scope": "time_entries settings",
"created_at": 1702300000
}

**Additional Data Fetched on Connect:**

GET https://moneybird.com/api/v2/administrations.json
→ [
{
"id": "123456789",
"name": "My Company BV",
...
}
]

**Stored Token Data:**
json
{
"access_token": "xxx",
"refresh_token": "xxx",
"expires_at": 1702307200,
"administration_id": "123456789",
"administration_name": "My Company BV"
}

Contacts (Clients) API



**Fetch Contacts:**

GET https://moneybird.com/api/v2/{admin_id}/contacts.json

**Contact Structure (from Moneybird):**
json
{
"id": "789012345",
"company_name": "Acme Corp",
"firstname": "John",
"lastname": "Doe",
"email": "john@acme.com",
...
}

**Fields We Use:**

| Field | Purpose |
|-------|---------|
|
id | Unique contact identifier |
|
company_name | Company name (preferred) |
|
firstname + lastname | Fallback if no company name |
|
email | Contact email |

**Create Contact:**

POST https://moneybird.com/api/v2/{admin_id}/contacts.json

{
"contact": {
"company_name": "New Client Inc"
}
}

Projects API



**Fetch Projects:**

GET https://moneybird.com/api/v2/{admin_id}/projects.json

**Project Structure (from Moneybird):**
json
{
"id": "123456",
"name": "Website Redesign",
"state": "active",
...
}

**Fields We Use:**

| Field | Purpose |
|-------|---------|
|
id | Unique project identifier |
|
name | Project name |
|
state | Filter for "active" only |

**Create Project:**

POST https://moneybird.com/api/v2/{admin_id}/projects.json

{
"project": {
"name": "New Project"
}
}

Time Entries API



**Fetch User ID (Required for Time Entries):**

GET https://moneybird.com/api/v2/{admin_id}/users.json
→ [{ "id": "user123", ... }]

**Create Time Entry:**

POST https://moneybird.com/api/v2/{admin_id}/time_entries.json

{
"time_entry": {
"user_id": "user123",
"started_at": "2024-01-08 09:00:00",
"ended_at": "2024-01-08 10:00:00",
"description": "Client Work",
"paused_duration": 0,
"project_id": "123456",
"contact_id": "789012"
}
}

**Response:**
json
{
"id": "456789",
"user_id": "user123",
"started_at": "2024-01-08T09:00:00.000+01:00",
"ended_at": "2024-01-08T10:00:00.000+01:00",
"description": "Client Work",
"project_id": "123456",
"contact_id": "789012",
...
}

**Update Time Entry:**

PATCH https://moneybird.com/api/v2/{admin_id}/time_entries/{id}.json

{
"time_entry": {
"started_at": "2024-01-08 09:30:00",
"ended_at": "2024-01-08 11:00:00",
"description": "Updated description",
"project_id": "123456",
"contact_id": "789012"
}
}

**Delete Time Entry:**

DELETE https://moneybird.com/api/v2/{admin_id}/time_entries/{id}.json

---

Data Flow Summary



┌─────────────────────────────────────────────────────────────────────────────┐
│ USER CREATES TIME BLOCK │
└─────────────────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────────────┐
│ Input: title, day, startTime, duration, projectId, clientId │
└─────────────────────────────────────────────────────────────────────────────┘

┌─────────────────┴─────────────────┐
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────┐
│ MONEYBIRD API │ │ GOOGLE CALENDAR API │
│ │ │ │
│ POST /time_entries.json │ │ POST /events │
│ { │ │ { │
│ user_id, │ │ summary: title, │
│ started_at, │ │ start: { dateTime }, │
│ ended_at, │ │ end: { dateTime }, │
│ description, │ │ colorId: "10", │
│ project_id, │ │ extendedProperties: { │
│ contact_id │ │ project, client, │
│ } │ │ weekplanner: "true", │
│ │ │ timeEntryId │
│ Returns: { id: "456" } │ │ } │
└───────────────────────────┘ │ } │
│ │ │
│ │ Returns: { id: "abc" } │
│ └───────────────────────────┘
│ │
└─────────────────┬─────────────────┘

┌─────────────────────────────────────────────────────────────────────────────┐
│ Stored References: │
│ - Google Calendar Event ID: "abc" │
│ - Moneybird Time Entry ID: "456" │
│ - Both linked via extendedProperties.private.timeEntryId │
└─────────────────────────────────────────────────────────────────────────────┘
`

External IDs to Store in Database



| External System | ID Field | Purpose |
|-----------------|----------|---------|
| Google Calendar |
event.id | Link to calendar event |
| Moneybird |
administration_id | Which Moneybird account |
| Moneybird |
time_entry.id | Link to time entry |
| Moneybird |
project.id | Link to project |
| Moneybird |
contact.id | Link to client/contact |
| Moneybird |
user.id` | Required for creating time entries |

---

Adding New Providers



Step 1: Implement Interface



// app/Services/Integrations/Calendar/Office365CalendarProvider.php
class Office365CalendarProvider implements CalendarProviderInterface
{
// ... implement all methods
}


Step 2: Add Config



// config/integrations.php
'providers' => [
'calendar' => [
'google_calendar' => [
'class' => GoogleCalendarProvider::class,
'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
'scopes' => ['https://www.googleapis.com/auth/calendar'],
],
'office365_calendar' => [
'class' => Office365CalendarProvider::class,
'client_id' => env('OFFICE365_CLIENT_ID'),
'client_secret' => env('OFFICE365_CLIENT_SECRET'),
'scopes' => ['Calendars.ReadWrite'],
],
],
'invoicing' => [
'moneybird' => [...],
'harvest' => [...],
],
],


Step 3: Register (Automatic via Config)



// IntegrationServiceProvider boots all configured providers