Craft Easy — Advanced Features Specification¶
Version: 1.0 Date: 2026-03-28 Status: Approved Related: specification.md
Contents¶
- Critical review of current architecture
- Multi-tenant architecture
- Flexible organization hierarchy + tagging
- GDPR: field tagging, depersonalization, soft delete
- Client funds accounts (Klientmedelskonto)
- Settlement and bookkeeping
- Payments (Stripe and others)
- White label
- BI export (BigQuery / Azure SQL)
- Implementation priority and dependencies
1. Critical Review of Current Architecture¶
What works well¶
| Component | Assessment |
|---|---|
| Resource class (CRUD + hooks) | Solid — flexible, tested, extensible |
| Auth (JWT, OTP, 2FA) | Solid — toggleable, modular |
| Cascade operations | Good — declarative, chain-aware |
| Audit log | Good — automatic via auto-hooks |
| Settings with feature flags | Good — everything toggleable |
What needs to change¶
1.1 Organization hierarchy is too rigid¶
Current: Fixed 5-level chain: Consortium → Region → Organization → Department → Unit
Problem: This is Airpark's structure, not a generic one. A booking system might need Company → Location. A school system might need District → School → Class. Forcing 5 levels on every project is wrong for a base system.
Solution: Make the hierarchy configurable. The base system provides the mechanism (parent-child relationships, cascade, scoping) but not the specific levels. Each project defines its own hierarchy.
# Current (rigid):
from craft_easy.models.organization import Consortium, Region, Organization, Department, Unit
# Proposed (flexible):
class Company(BaseDocument):
name: str
class HierarchyConfig:
level = 0 # Root
children = ["Location"]
class Location(BaseDocument):
name: str
parent_id: PydanticObjectId # Points to Company
parent_name: str = Field(json_schema_extra={"readonly": True})
class HierarchyConfig:
level = 1
parent_model = "Company"
children = ["Team"]
Decision needed: Should we keep the 5-level hierarchy as a "preset" and add flexibility? Or remove it entirely and let each project define its own?
Recommendation: Remove the fixed hierarchy from the base system. Move Consortium → Unit to the Airpark project. The base system provides BaseDocument with CascadeConfig — that's enough to build any hierarchy.
1.2 Multi-tenant is missing¶
Current: No tenant isolation at all. All data lives in one pool.
Problem: The user wants each tenant to have its own access rights, potentially its own client funds account, white label config, etc.
Solution: See section 2.
1.3 Access control is user-centric, not tenant-centric¶
Current: Users have access groups → features. But there's no concept of "this user belongs to tenant X and can only see tenant X's data."
Problem: Multi-tenant requires every query to be scoped to a tenant automatically.
Solution: See sections 2 and 3.
2. Multi-Tenant Architecture¶
2.1 Approach: Shared database, tenant-scoped documents¶
Each document has a tenant_id field. All queries are automatically filtered by tenant.
Why not separate databases per tenant? - The base system should be simple to deploy (one database) - Separate databases add operational complexity - Cosmos DB / Atlas pricing is per-cluster, not per-database - The legacy ESI API used separate databases — it added complexity for minimal benefit
Why not schema-based isolation? - MongoDB doesn't have schemas - Too rigid for flexible hierarchies
2.2 Tenant model¶
class Tenant(BaseDocument):
"""A tenant / customer / organization using the system."""
name: str
slug: str # URL-safe identifier: "airpark", "hobby-project-1"
is_enabled: bool = True
# White label
display_name: Optional[str] = None
logo_url: Optional[str] = None
primary_color: Optional[str] = None
domain: Optional[str] = None # Custom domain for white label
# Features (what modules are enabled for this tenant)
enabled_features: list[str] = [] # ["payments", "bi-export", "gdpr"]
# Limits
max_users: Optional[int] = None
max_storage_mb: Optional[int] = None
class Settings:
name = "tenants"
2.3 Tenant scoping on BaseDocument¶
class BaseDocument(Document):
tenant_id: Optional[PydanticObjectId] = Field(
default=None, json_schema_extra={"readonly": True}
)
# ... existing fields
class TenantConfig:
"""Override in subclasses."""
tenant_scoped = True # True = auto-filter by tenant
# False for global resources like Tenants themselves
2.4 Automatic tenant filtering¶
The Resource class automatically adds tenant_id filter to all queries:
# In Resource.build(), the list endpoint becomes:
filters["tenant_id"] = current_user.tenant_id # Auto-injected
# POST: tenant_id auto-set from current user
item.tenant_id = current_user.tenant_id
# GET/PATCH/DELETE: verify item belongs to user's tenant
if item.tenant_id != current_user.tenant_id:
raise HTTPException(403)
2.5 Token payload extension¶
class TokenPayload(BaseModel):
# ... existing fields
tenant_id: Optional[str] = None
tenant_slug: Optional[str] = None
2.6 Cross-tenant access (system users)¶
System users / service tokens can optionally bypass tenant filtering for admin operations.
3. Flexible Organization Hierarchy + Tagging¶
3.1 Remove fixed hierarchy from base system¶
The 5-level Consortium → Unit hierarchy moves to Airpark's project code. The base system provides only the tools to build hierarchies.
3.2 Tagging system¶
Instead of a fixed hierarchy, provide a flexible tagging/grouping system:
class Tag(BaseDocument):
"""Flexible tag for grouping and filtering."""
name: str
category: str # "location", "department", "project", "custom"
color: Optional[str] = None
icon: Optional[str] = None
parent_tag_id: Optional[PydanticObjectId] = None # Hierarchical tags
class TenantConfig:
tenant_scoped = True
class Settings:
name = "tags"
3.3 Taggable documents¶
Any document can be tagged by adding tags to the model:
class Booking(BaseDocument):
# ... business fields
tags: list[PydanticObjectId] = Field(default_factory=list)
The Resource class auto-detects the tags field and enables tag-based filtering.
3.4 Tag-based filtering (built into Resource)¶
# Items with ANY of these tags (OR):
GET /bookings?tags__in=tag1_id,tag2_id,tag3_id
# Items with ALL of these tags (AND):
GET /bookings?tags__all=tag1_id,tag2_id
# Items with these tags but NOT these:
GET /bookings?tags__in=tag1_id,tag2_id&tags__nin=tag3_id
# Combined with other filters:
GET /bookings?tags__in=stockholm_id&status=confirmed&sort=-created_at
3.5 Tags and access control are separate concerns¶
Tags = data filtering (what you see in a query) Access control = permissions (what you're allowed to do)
They do NOT replace each other. A user might have access to all bookings (via AccessGroup features) but choose to filter by geography tag. Or a user might only have access to "bookings.list" feature and no access to "bookings.delete" — regardless of tags.
# Access control: WHAT you can do
access_group.features = ["bookings.list", "bookings.create"]
# Tags: HOW you filter what you see
GET /bookings?tags__in=stockholm,gothenburg
4. GDPR: Field Tagging, Depersonalization, Soft Delete¶
4.1 GDPR field tagging¶
Fields containing personal data are tagged at the model level:
class Customer(BaseDocument):
# Not GDPR
customer_number: str
status: str
# GDPR tagged
name: str = Field(json_schema_extra={"gdpr": True, "gdpr_category": "identity"})
email: EmailStr = Field(json_schema_extra={"gdpr": True, "gdpr_category": "contact"})
phone: str = Field(json_schema_extra={"gdpr": True, "gdpr_category": "contact"})
address: str = Field(json_schema_extra={"gdpr": True, "gdpr_category": "contact"})
personal_id: str = Field(json_schema_extra={"gdpr": True, "gdpr_category": "identity"})
# GDPR status
is_depersonalized: bool = Field(default=False, json_schema_extra={"readonly": True})
depersonalized_at: Optional[datetime] = Field(default=None, json_schema_extra={"readonly": True})
4.2 Depersonalization rules¶
GDPR_DEPERSONALIZATION_RULES = {
"identity": lambda value: "DEPERSONALIZED",
"contact": lambda value: "***",
"email": lambda value: "depersonalized@removed.invalid",
"phone": lambda value: "+00000000000",
"address": lambda value: "Address removed",
"free_text": lambda value: "[Content removed per GDPR]",
}
4.3 Depersonalization endpoint¶
- Replaces all GDPR-tagged fields with depersonalized values
- Sets
is_depersonalized = Trueanddepersonalized_at = now - Logs the action in audit log
- Document remains in the database (soft delete of personal data)
4.4 Access to depersonalized documents¶
# Normal user: depersonalized documents are hidden from list results
GET /customers → only shows is_depersonalized=false
# User with "gdpr.view_depersonalized" feature:
GET /customers?include_depersonalized=true → shows all, with depersonalized values
# No one can see original values after depersonalization — they are overwritten, not hidden
4.5 GDPR schema endpoint¶
Returns all models and their GDPR-tagged fields — useful for compliance documentation.
5. Client Funds Accounts (Klientmedelskonto)¶
5.1 Concept¶
A client funds account is a legal requirement in some industries (law firms, real estate agents) where client money must be kept separate from the business's own money.
5.2 Jurisdiction support¶
| Region | Status | Regulatory basis |
|---|---|---|
| Sweden | Primary target | Klientmedelslag, Fastighetsmäklarlag |
| EU | Must be supportable | PSD2, national variations |
| North America | Must be supportable | IOLTA (US), trust accounting (CA) |
| Other | Extensible | Add as needs arise |
Jurisdiction-specific rules (interest calculation, reporting) are configurable per deployment.
5.3 Two modes¶
| Mode | Description | Use case |
|---|---|---|
| System-level | One client funds account owned by the system operator | Simple, the operator holds all client funds |
| Tenant-level | Each tenant has its own client funds account | Complex, each tenant manages their own |
Configurable per deployment:
5.3 Account model¶
class ClientFundsAccount(BaseDocument):
"""Client funds (klientmedelskonto) — separate from operating funds."""
tenant_id: Optional[PydanticObjectId] = None # None = system-level
account_name: str
bank_name: Optional[str] = None
account_number: Optional[str] = Field(json_schema_extra={"gdpr": True})
currency: str = "SEK"
balance: Decimal = Decimal("0.00") # Computed from transactions
is_reconciled: bool = True
class Settings:
name = "client_funds_accounts"
class ClientFundsTransaction(BaseDocument):
"""Individual transaction on a client funds account."""
account_id: PydanticObjectId
type: str # "deposit" | "withdrawal" | "transfer"
amount: Decimal
currency: str = "SEK"
description: str
reference: Optional[str] = None
related_entity: Optional[str] = None # "booking/abc123"
counterpart: Optional[str] = None # Who the money belongs to
reconciled: bool = False
reconciled_at: Optional[datetime] = None
class Settings:
name = "client_funds_transactions"
6. Settlement and Bookkeeping¶
6.1 Cost types (configurable)¶
Built-in cost types:
| Code | Name (SV) | Name (EN) | Category |
|---|---|---|---|
| Principal | |||
capital |
Kapital | Capital | Principal |
| Financial | |||
interest |
Ränta | Interest | Financial |
late_interest |
Dröjsmålsränta | Late payment interest | Financial |
currency_surcharge |
Valutapåslag | Currency surcharge | Financial |
| Collection | |||
reminder_fee |
Påminnelseavgift | Reminder fee | Collection |
late_fee |
Förseningsavgift | Late fee (combined) | Collection |
collection_fee |
Inkassoavgift | Collection fee | Collection |
credit_check_fee |
Kreditupplysningsavgift | Credit check fee | Collection |
| Legal / Enforcement | |||
enforcement_fee |
Kronofogdeavgift | Enforcement fee | Legal |
enforcement_application_fee |
Ansökningsavgift | Application fee | Legal |
payment_order_fee |
Betalningsföreläggande | Payment order fee | Legal |
enforcement_basic_fee |
Grundavgift verkställighet | Enforcement basic fee | Legal |
enforcement_sale_fee |
Försäljningsavgift | Enforcement sale fee | Legal |
legal_fee |
Juridisk kostnad | Legal fee | Legal |
| Administrative | |||
invoice_fee |
Fakturaavgift | Invoice fee | Administrative |
installment_fee |
Aviavgift | Installment fee | Administrative |
setup_fee |
Uppläggningsavgift | Setup fee | Administrative |
admin_fee |
Administrationsavgift | Administrative fee | Administrative |
processing_fee |
Expeditionsavgift | Processing fee | Administrative |
cancellation_fee |
Avtalsbrytningsavgift | Cancellation fee | Administrative |
annual_fee |
Årsavgift | Annual fee | Administrative |
| Service fees | |||
service_fee |
Serviceavgift | Service fee | Service |
subscription_fee |
Prenumerationsavgift | Subscription fee | Service |
license_fee |
Licensavgift | License fee | Service |
membership_fee |
Medlemsavgift | Membership fee | Service |
booking_fee |
Bokningsavgift | Booking fee | Service |
transaction_fee |
Transaktionsavgift | Transaction fee | Service |
platform_fee |
Plattformsavgift | Platform fee | Service |
| Logistics | |||
shipping |
Fraktkostnad | Shipping cost | Logistics |
return_fee |
Returavgift | Return fee | Logistics |
handling_fee |
Hanteringsavgift | Handling fee | Logistics |
storage_fee |
Lagringsavgift | Storage fee | Logistics |
| Taxes & duties | |||
vat |
Moms | VAT | Tax |
customs_duty |
Tullavgift | Customs duty | Tax |
excise_tax |
Punktskatt | Excise tax | Tax |
| Discounts & adjustments | |||
discount |
Rabatt | Discount | Adjustment |
write_off |
Avskrivning | Write-off | Adjustment |
rounding |
Öresavrundning | Rounding | Adjustment |
credit |
Kreditering | Credit | Adjustment |
goodwill |
Goodwill-ersättning | Goodwill compensation | Adjustment |
| Insurance & guarantees | |||
insurance_fee |
Försäkringsavgift | Insurance fee | Insurance |
guarantee_fee |
Garantiavgift | Guarantee fee | Insurance |
deposit |
Deposition | Deposit | Insurance |
52 built-in cost types across 10 categories. Each tenant enables only the ones they need. Custom types can be added at any time.
Note on Swedish enforcement costs (Kronofogden):
The Swedish Enforcement Authority (Kronofogden) has multiple cost types at different stages:
| Stage | Cost type | Typical amount (2026) |
|---|---|---|
| Ansökan om betalningsföreläggande | enforcement_application_fee |
300 SEK |
| Betalningsföreläggande utfärdat | payment_order_fee |
375 SEK |
| Verkställighet (grundavgift) | enforcement_basic_fee |
600 SEK |
| Utmätning/försäljning | enforcement_sale_fee |
Varies |
These are all configurable — tenants can remove ones they don't use or add new ones.
Note on late fee (förseningsavgift):
Some Swedish businesses use a single late fee (late_fee) instead of separate reminder + collection fees. For example, 450 SEK that covers both reminder and collection costs. When using late_fee, the reminder_fee and collection_fee are typically not used — controlled via the settlement order which determines which cost types apply.
All cost types are fully configurable per tenant. The built-in ones are defaults — tenants can:
- Remove built-in types they don't use
- Add custom types (e.g. admin_fee, service_charge)
- Set different amounts, VAT rates, and labels
- Use late_fee instead of reminder_fee + collection_fee
class CostType(BaseDocument):
"""Configurable cost type. Tenants can add, remove, or modify."""
code: str # "late_fee", "admin_fee", "enforcement_application_fee", etc.
name: str
name_i18n: dict[str, str] = {} # {"sv": "Förseningsavgift", "en": "Late fee"}
category: str # "financial", "collection", "legal", "administrative", "logistics"
is_system: bool = False # True = built-in default, can still be disabled per tenant
is_enabled: bool = True # Tenant can disable cost types they don't use
is_taxable: bool = False
default_vat_rate: Decimal = Decimal("0.00")
default_amount: Optional[Decimal] = None # Pre-filled amount (e.g. 60 for reminder)
sort_order: int = 0 # Display order in UI
# Legal reference (for regulated fees)
legal_basis: Optional[str] = None # "Inkassolagen §5", "KFM förordning"
jurisdiction: Optional[str] = None # "SE", "EU", "US"
class TenantConfig:
tenant_scoped = True
class Settings:
name = "cost_types"
6.2 Account structure — multi-entity bookkeeping¶
Bookkeeping must be supported for ALL parties in the ecosystem:
| Entity | Has its own books | Example |
|---|---|---|
| System Owner | Yes — platform revenue, fees, partner payouts | Easy Software System AB |
| Each Tenant | Yes — customer revenue, costs, settlements | Airpark AB, Healthcare Co |
| Each Partner | Yes — revenue share received | Sales Partner AB |
All three use the SAME bookkeeping model. The entity_type + entity_id fields scope each account/entry to its owner.
class BookkeepingEntity(BaseModel):
"""Identifies who owns a set of books."""
entity_type: str # "system_owner" | "tenant" | "partner"
entity_id: Optional[PydanticObjectId] = None # None for system owner (there's only one)
class Account(BaseDocument):
"""Bookkeeping account — scoped to an entity (tenant, system owner, or partner)."""
# Owner
entity_type: str # "system_owner" | "tenant" | "partner"
entity_id: Optional[PydanticObjectId] = None
# Account
code: str # "1930" (bank), "3001" (revenue), etc.
name: str
type: str # "asset" | "liability" | "revenue" | "expense" | "equity"
currency: str = "SEK"
is_system_account: bool = False # True = auto-created, cannot be deleted
class TenantConfig:
tenant_scoped = False # Accounts span entities — access controlled via entity_type
class Settings:
name = "accounts"
indexes = [
[("entity_type", 1), ("entity_id", 1), ("code", 1)],
]
class JournalEntry(BaseDocument):
"""A complete accounting transaction. Lines must balance (total debit = total credit)."""
entity_type: str
entity_id: Optional[PydanticObjectId] = None
date: datetime
description: str
reference: Optional[str] = None # Invoice number, payment ID, etc.
source: Optional[str] = None # "payment", "settlement", "invoice", "manual"
source_id: Optional[PydanticObjectId] = None
currency: str
lines: list[JournalLine]
is_posted: bool = False # Draft until posted — posted entries are immutable
posted_at: Optional[datetime] = None
posted_by: Optional[PydanticObjectId] = None
class TenantConfig:
tenant_scoped = False
class Settings:
name = "journal_entries"
indexes = [
[("entity_type", 1), ("entity_id", 1), ("date", -1)],
]
class JournalLine(BaseModel):
"""One line in a journal entry."""
account_id: PydanticObjectId
account_code: str
account_name: Optional[str] = None
debit: Decimal = Decimal("0.00")
credit: Decimal = Decimal("0.00")
description: Optional[str] = None
cost_type: Optional[str] = None # Links to CostType for categorization
6.3 How a payment generates journal entries for ALL parties¶
When a customer pays 1000 SEK and the split is 80/15/5:
Tenant's books:
Debit: 1930 Bank 1000.00
Credit: 3001 Sales revenue 800.00
Credit: 2440 System owner payable 150.00
Credit: 2441 Partner payable 50.00
System owner's books:
Partner's books:
When settlement runs and system owner gets paid:
Tenant's books:
System owner's books:
6.4 Default chart of accounts (kontplan)¶
Each entity gets a default chart of accounts on creation. Extensible — entities can add custom accounts.
DEFAULT_CHART_OF_ACCOUNTS = [
# Assets (Tillgångar)
{"code": "1510", "name": "Accounts receivable", "type": "asset"},
{"code": "1930", "name": "Bank account", "type": "asset"},
{"code": "1940", "name": "Client funds account", "type": "asset"},
# Liabilities (Skulder)
{"code": "2440", "name": "Accounts payable - system owner", "type": "liability"},
{"code": "2441", "name": "Accounts payable - partner", "type": "liability"},
{"code": "2442", "name": "Accounts payable - subcontractor", "type": "liability"},
{"code": "2610", "name": "VAT payable", "type": "liability"},
{"code": "2910", "name": "Client funds liability", "type": "liability"},
# Revenue (Intäkter)
{"code": "3001", "name": "Sales revenue", "type": "revenue"},
{"code": "3002", "name": "Platform fees", "type": "revenue"},
{"code": "3003", "name": "Revenue share", "type": "revenue"},
{"code": "3590", "name": "Interest income", "type": "revenue"},
# Expenses (Kostnader)
{"code": "5010", "name": "Payment provider fees", "type": "expense"},
{"code": "6570", "name": "Collection costs", "type": "expense"},
]
6.5 External accounting system export¶
Each entity (system owner, tenant, partner) can connect to an external accounting system and export bookkeeping data automatically.
class AccountingIntegration(BaseDocument):
"""Connection to an external accounting system for a specific entity."""
entity_type: str # "system_owner" | "tenant" | "partner"
entity_id: Optional[PydanticObjectId] = None
provider: str # "fortnox" | "visma" | "xero" | "quickbooks" | "sie" | "custom_api"
is_enabled: bool = True
# Provider-specific config (stored encrypted)
credentials: dict = {} # API keys, tokens, etc.
settings: AccountingExportSettings
# Sync state
last_sync_at: Optional[datetime] = None
last_sync_status: Optional[str] = None # "success" | "partial" | "failed"
last_sync_error: Optional[str] = None
class TenantConfig:
tenant_scoped = False # Scoped by entity_type/entity_id
class Settings:
name = "accounting_integrations"
class AccountingExportSettings(BaseModel):
"""How to map and export data to the external system."""
export_mode: str = "manual" # "manual" | "scheduled" | "realtime"
schedule: Optional[str] = None # Cron: "0 3 * * *" (daily 3 AM)
# Account mapping: internal code → external code
account_mapping: dict[str, str] = {}
# e.g. {"1930": "1920", "3001": "3010"} — when external system uses different codes
# What to export
export_journal_entries: bool = True
export_invoices: bool = True
export_payments: bool = True
# Format (for file-based exports)
file_format: Optional[str] = None # "sie4" | "csv" | "json"
Supported providers¶
| Provider | Type | Region | Integration |
|---|---|---|---|
| Fortnox | API | Sweden | REST API — create vouchers, invoices |
| Visma eEkonomi | API | Nordics | REST API |
| Xero | API | Global | REST API |
| QuickBooks | API | Global | REST API |
| SIE4 | File export | Sweden | Standard file format — import manually |
| Custom API | Webhook | Any | POST journal entries to custom endpoint |
Abstract provider interface¶
class AccountingExportProvider(ABC):
"""Abstract interface for accounting system integrations."""
@abstractmethod
async def export_journal_entry(self, entry: JournalEntry, mapping: dict[str, str]) -> str:
"""Export a journal entry. Returns external ID."""
@abstractmethod
async def export_invoice(self, invoice: Invoice, mapping: dict[str, str]) -> str:
"""Export an invoice. Returns external ID."""
@abstractmethod
async def verify_connection(self) -> bool:
"""Test that credentials work."""
async def export_batch(self, entries: list[JournalEntry], mapping: dict[str, str]) -> list[str]:
"""Export multiple entries. Default: one by one. Override for batch API."""
return [await self.export_journal_entry(e, mapping) for e in entries]
class SIE4ExportProvider(AccountingExportProvider):
"""Swedish SIE4 file format — generates downloadable file."""
async def export_journal_entry(self, entry, mapping):
# Generates SIE4 formatted text
...
async def generate_file(self, entries: list[JournalEntry], period: str) -> bytes:
"""Generate a complete SIE4 file for a period."""
...
class FortnoxProvider(AccountingExportProvider):
"""Fortnox API integration."""
...
class CustomWebhookProvider(AccountingExportProvider):
"""Sends journal entries to a custom URL (webhook)."""
...
Export flow¶
Journal entry posted in Craft Easy
│
▼
Entity has AccountingIntegration?
│
┌───┴───┐
No Yes
│ │
Done ▼
Export mode?
│
┌───┼───────────┐
│ │ │
manual scheduled realtime
│ │ │
│ Cron job Immediate
│ queues export via
│ export provider API
│ │ │
▼ ▼ ▼
User triggers Provider.export_journal_entry()
export from │
admin UI ▼
│ External system
▼ (Fortnox, Visma, etc.)
Download
SIE4/CSV
Each entity independently configures if/how/when their bookkeeping data is exported. System owner might use Fortnox, Tenant A might use Visma, Tenant B might just download SIE4 files, Partner C might not export at all.
6.6 Settlement¶
Settlement = calculating what each party is owed and generating journal entries:
class Settlement(BaseDocument):
"""A settlement run — calculates and distributes funds."""
period_start: datetime
period_end: datetime
status: str # "draft" | "calculated" | "approved" | "posted"
total_revenue: Decimal
total_fees: Decimal
total_payouts: Decimal
lines: list[SettlementLine]
class SettlementLine(BaseModel):
"""One line in a settlement."""
tenant_id: PydanticObjectId
description: str
gross_amount: Decimal
fee_amount: Decimal
net_amount: Decimal
payout_status: str # "pending" | "paid"
7. Payments (Stripe and Others)¶
7.1 Abstract payment provider¶
class PaymentProvider(ABC):
"""Abstract interface for payment providers."""
@abstractmethod
async def create_checkout(self, amount: Decimal, currency: str, metadata: dict) -> str:
"""Create a checkout session. Returns redirect URL."""
@abstractmethod
async def handle_webhook(self, payload: bytes, signature: str) -> PaymentEvent:
"""Process incoming webhook."""
@abstractmethod
async def create_payout(self, amount: Decimal, destination: str) -> str:
"""Create a payout to a connected account."""
@abstractmethod
async def create_refund(self, payment_id: str, amount: Optional[Decimal]) -> str:
"""Refund a payment."""
7.2 Planned providers¶
| Provider | Region | Type | Priority |
|---|---|---|---|
| Stripe | Global | Cards, bank transfers | Phase 8 |
| Klarna | EU, NA | Buy now pay later, invoicing | Phase 8 |
| Swish | Sweden | Mobile payments | Phase 8 |
| EU equivalents | Per country | TBD as needed | Later |
NOT included: Trustly.
class StripeProvider(PaymentProvider):
"""Stripe implementation with Connect for marketplace payouts."""
class KlarnaProvider(PaymentProvider):
"""Klarna — invoicing, installments, pay later."""
class SwishProvider(PaymentProvider):
"""Swish — Swedish mobile payments."""
7.3 Payment model¶
class Payment(BaseDocument):
"""A payment transaction."""
provider: str # "stripe" | "swish" | "trustly"
provider_id: str # External ID from provider
amount: Decimal
currency: str
status: str # "pending" | "completed" | "failed" | "refunded"
metadata: dict = {}
related_entity: Optional[str] = None # "booking/abc123"
class TenantConfig:
tenant_scoped = True
8. White Label¶
8.1 Tenant-level branding¶
Stored on the Tenant model (section 2.2):
class TenantBranding(BaseModel):
display_name: str
logo_url: Optional[str] = None
favicon_url: Optional[str] = None
primary_color: str = "#2563eb"
secondary_color: Optional[str] = None
custom_domain: Optional[str] = None # "admin.their-company.com"
custom_css: Optional[str] = None
8.2 Branding endpoint¶
Returns branding for the current tenant. The admin app uses this to theme itself.
8.3 Custom domains¶
Each tenant can optionally have its own domain:
admin.craft-easy.dev/tenant-slug → Default
admin.their-company.com → Custom domain (CNAME to admin.craft-easy.dev)
Handled at the infrastructure level (Azure Front Door / reverse proxy).
9. BI Export (BigQuery / Azure SQL)¶
9.1 Concept¶
Export data to a relational database for analytics, with GDPR-sensitive fields excluded or depersonalized.
9.2 Export pipeline¶
MongoDB (source of truth)
│
▼
Export Service (scheduled or triggered)
│
├── Read documents from MongoDB
├── Apply GDPR filter (remove/depersonalize tagged fields)
├── Flatten documents to relational rows
│
▼
Target: BigQuery (GCP) or Azure SQL
9.3 GDPR-aware export¶
class BIExportConfig(BaseModel):
"""Configuration for BI export of a resource."""
resource: str
target_table: str
gdpr_mode: str # "exclude" | "depersonalize" | "hash"
# "exclude": remove GDPR fields entirely
# "depersonalize": replace with generic values
# "hash": one-way hash (allows counting unique users without identifying them)
schedule: str # Cron expression: "0 2 * * *" (daily at 2 AM)
flatten_depth: int = 1 # How deep to flatten nested objects
9.4 Export registry¶
Each model can declare its BI export config:
class Booking(BaseDocument):
class BIExportConfig:
target_table = "bookings"
gdpr_mode = "exclude"
schedule = "0 2 * * *"
flatten_fields = {
"customer_name": "customer.name", # Excluded (GDPR)
"zone_name": "zone.name", # Included
}
10. Implementation Priority and Dependencies¶
Dependency graph¶
Multi-tenant (MUST BE FIRST)
│
┌─────────────┼──────────────┐
│ │ │
Flexible tags GDPR White label
│ │
│ BI export
│
Access control
(tag-scoped)
Client funds
│
Bookkeeping
│
Payments
│
Settlement
Implementation phases¶
| Phase | Module | Weeks | Depends on |
|---|---|---|---|
| 1 | Multi-tenant (tenant model, scoping, token) | 2 | — |
| 2 | Flexible tags + tag-based filtering | 1 | Multi-tenant |
| 3 | Tag-scoped access control | 1 | Tags |
| 4 | GDPR (field tagging, depersonalization) | 2 | Multi-tenant |
| 5 | White label (tenant branding) | 1 | Multi-tenant |
| 6 | BI export | 2 | GDPR |
| 7 | Payment provider (abstract + Stripe) | 2 | Multi-tenant |
| 8 | Bookkeeping (accounts, journal entries) | 2 | Multi-tenant |
| 9 | Client funds accounts | 1 | Bookkeeping |
| 10 | Settlement | 2 | Bookkeeping + Payments |
Decisions Made (2026-03-28)¶
All open questions resolved:
| Question | Decision |
|---|---|
| Organization hierarchy | Remove from base system. Move Consortium→Unit to Airpark. Base system provides BaseDocument + CascadeConfig + Tags. Breaking change — do before multi-tenant. |
| Multi-tenant scope | Shared DB with tenant_id. Tenant is flexible — can be a company, a consortium with many companies, or anything. Same system supports both. |
| Client funds jurisdiction | Sweden first, Europe and North America must be supportable. Extensible for additional jurisdictions as needs arise. |
| Cost types | Configurable per deployment. Built-in: capital, interest, reminders (påminnelser), debt collection (inkasso), enforcement (kronofogden), installment plans (amorteringsplaner), invoice fees (aviavgifter), shipping, invoice fees. Extensible — tenants can add custom cost types. |
| BI export | Both BigQuery AND Azure SQL. Provider-agnostic export service with pluggable targets. |
| Payment providers | Stripe, Klarna, Swish and EU equivalents. NOT Trustly. Abstract provider interface — add more as needed. |
| Tags vs access control | Tags do NOT replace access control. Tags are a data filtering mechanism. Access control remains feature-based (AccessGroup + features). Tags enable things like geographic filtering (regions, cities) and functional filtering (ophthalmology, thoracic surgery) — each tenant builds their own tag structure. |
Tag structure examples¶
Healthcare (private):
Geography:
├── Region North
│ ├── Stockholm
│ └── Uppsala
├── Region South
│ ├── Malmö
│ └── Lund
└── Region West
└── Gothenburg
Specialty:
├── Ophthalmology
├── Thoracic Surgery
├── Orthopedics
└── Cardiology
Query: "Show all appointments in Region South + Ophthalmology"
→ GET /appointments?tags__in=region-south&tags__in=ophthalmology
Query: "Show all in Region North but NOT Uppsala"
→ GET /appointments?tags__in=region-north&tags__nin=uppsala
Parking (Airpark):
Geography:
├── Sweden
│ ├── Stockholm
│ └── Gothenburg
└── Norway
└── Oslo
Zone type:
├── Airport
├── City center
└── Residential
Each tenant builds their own structure. No predefined levels.
Implementation order (updated)¶
| Phase | Module | Weeks | Depends on |
|---|---|---|---|
| 0 | Remove fixed hierarchy from base system | 1 | — |
| 1 | Multi-tenant (tenant model, flexible scoping, token) | 2 | Phase 0 |
| 2 | Hierarchical tags + tag-based filtering | 1 | Phase 1 |
| 3 | GDPR (field tagging, depersonalization, soft delete) | 2 | Phase 1 |
| 4 | White label (tenant branding) | 1 | Phase 1 |
| 5 | BI export (abstract + BigQuery + Azure SQL) | 2 | Phase 3 |
| 6 | Bookkeeping (accounts, journal entries, cost types) | 2 | Phase 1 |
| 7 | Client funds (Sweden, extensible to EU/NA) | 1 | Phase 6 |
| 8 | Payment providers (abstract + Stripe + Klarna + Swish) | 2 | Phase 1 |
| 9 | Settlement (fees, payouts, reconciliation) | 2 | Phase 6 + 8 |