Skip to content

Craft Easy — Advanced Features Specification

Version: 1.0 Date: 2026-03-28 Status: Approved Related: specification.md


Contents

  1. Critical review of current architecture
  2. Multi-tenant architecture
  3. Flexible organization hierarchy + tagging
  4. GDPR: field tagging, depersonalization, soft delete
  5. Client funds accounts (Klientmedelskonto)
  6. Settlement and bookkeeping
  7. Payments (Stripe and others)
  8. White label
  9. BI export (BigQuery / Azure SQL)
  10. 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

POST /admin/gdpr/depersonalize/{resource}/{id}
  • Replaces all GDPR-tagged fields with depersonalized values
  • Sets is_depersonalized = True and depersonalized_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

GET /admin/gdpr/schema

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:

class Settings:
    CLIENT_FUNDS_MODE: str = "system"  # "system" | "tenant" | "disabled"

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:

Debit:  1510 Receivable Tenant A  150.00
Credit: 3001 Platform revenue              150.00

Partner's books:

Debit:  1510 Receivable (via platform)  50.00
Credit: 3001 Revenue share                      50.00

When settlement runs and system owner gets paid:

Tenant's books:

Debit:  2440 System owner payable  150.00
Credit: 1930 Bank                           150.00

System owner's books:

Debit:  1930 Bank                  150.00
Credit: 1510 Receivable Tenant A            150.00

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

GET /tenant/branding

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