Skip to content

Marketplace — Plugin-Store (Ist-Stand)

Status: Phasen 1-4 LIVE (2026-05-01). Vorgaengerdokument: process-engine-phase-10-marketplace.md (Konzept).

Der Plugin-Store ist Prilogs zentraler Verteilungs-Mechanismus fuer Flows, Anleitungen und Apps. Tenants installieren mit einem Klick (oder mit Confirm-Dialog bei Bezahl-Items), Drittanbieter reichen Bundles via API ein, Prilog reviewt und published, Stripe wickelt Zahlung und Vendor-Auszahlung ab.


Architektur-Ueberblick

                           ┌─────────────────────────┐
                           │  marketplace_items      │
                           │  (Catalog, published)   │
                           └────────────┬────────────┘
                                        │ 1..n

              ┌─────────────────┐  ┌─────────────────────┐  ┌──────────────────┐
              │  Tenant         │  │  Stripe-Subscription│  │  Vendor          │
              │  installiert    │──│  + transfer_data    │──│  Connect-Account │
              └─────────────────┘  └─────────────────────┘  └──────────────────┘

                       │ submission → review → approve

              ┌─────────────────────────┐
              │  marketplace_submissions│
              │  (Vendor → Prilog)      │
              └─────────────────────────┘

Drei Haupt-Entitaeten:

  1. MarketplaceItem — ein installierbares Item (Flow oder App). status: draft|published|archived. Hat optional Pricing + Stripe-Price-ID + Vendor.
  2. MarketplaceVendor — Drittanbieter (oder Prilog selbst). Stripe-Connect-Express-Account, charges/payouts-Status, Provision (%).
  3. MarketplaceSubmission — eingereichtes Bundle pending Review. Wird beim Approve in ein MarketplaceItem ueberfuehrt.

Plus: TenantMarketplaceSubscription — bildet ab, welcher Tenant was installiert hat. Speichert die stripeSubscriptionId (separate Sub pro Item, damit Connect-Splits per-Item gehen).


Datenbank-Schema

marketplace_items

sql
id              VARCHAR(50)   PK
item_type       VARCHAR(20)   -- 'flow' | 'app'
slug            VARCHAR(80)   UNIQUE
name            VARCHAR(120)
description     TEXT
icon_emoji      VARCHAR(10)
category        VARCHAR(40)   -- 'crisis' | 'organization' | 'education' | 'communication' | 'management' | 'tools' | 'operations' | 'other'
vendor_id       VARCHAR(50)   → marketplace_vendors.id
vendor_name     VARCHAR(120)  -- denormalisiert
version         VARCHAR(20)
status          VARCHAR(20)   -- 'draft' | 'published' | 'archived'
price_model     VARCHAR(20)   -- null | 'flat-monthly' | 'per-active-user'
price_cents     INTEGER
stripe_product_id VARCHAR(120) -- on-demand erzeugt bei Pricing
stripe_price_id   VARCHAR(120)
bundle          JSONB         -- { templateExport: ... } für Flows | { manifest: ... } für Apps
screenshots     JSONB         -- []
published_at    TIMESTAMPTZ
created_at      TIMESTAMPTZ
updated_at      TIMESTAMPTZ

marketplace_vendors

sql
id                       VARCHAR(50)   PK
name                     VARCHAR(120)
contact_email            VARCHAR(255)
country                  VARCHAR(2)    -- ISO-2, default 'DE'
stripe_account_id        VARCHAR(120)  UNIQUE  -- Connect Express
stripe_account_status    VARCHAR(30)   -- 'pending' | 'active' | 'restricted'
charges_enabled          BOOLEAN
payouts_enabled          BOOLEAN
application_fee_percent  NUMERIC(5,2)  -- default 30 (Prilog)
created_at, updated_at

marketplace_submissions

sql
id, item_type, name, description, icon_emoji, category, version
vendor_id        → marketplace_vendors.id
vendor_name, vendor_email
price_model, price_cents
bundle           JSONB
status           VARCHAR(20)  -- 'pending' | 'approved' | 'rejected' | 'needs-changes'
review_notes     TEXT
reviewed_at, reviewed_by
approved_item_id → marketplace_items.id

tenant_marketplace_subscriptions

sql
id, tenant_id, item_id
installed_version            VARCHAR(20)
stripe_subscription_id       VARCHAR(120) -- die separate Sub pro Item
stripe_subscription_item_id  VARCHAR(120) -- erstes Item der Sub
status                       VARCHAR(20)  -- 'active' | 'cancelled'
installed_at, cancelled_at
installed_template_id        VARCHAR(50)  -- bei Flow-Install: id des kopierten Templates

UNIQUE (tenant_id, item_id)

Migrations: 0025_marketplace, 0026_marketplace_paid, 0027_marketplace_submissions.


Bundle-Formate

Flow-Bundle

json
{
  "templateExport": {
    "format": "prilog.process-engine/v1",
    "template": { "appKind": "guide", "name": "...", "description": "...", "metadata": {} },
    "components": [{ "_localId": "s1", "kind": "guide.screen", "label": "...", "config": {}, "sortOrder": 0 }],
    "edges": [{ "sourceLocalId": "s1b", "targetLocalId": "s2", "condition": { "type": "always" }, "sortOrder": 0 }]
  }
}

Beim Install ruft installItem importTemplateForTenant() auf — selbe Logik wie POST /process/templates/import. Ergebnis: ein neues ProcessTemplate mit Components + Edges im Tenant.

App-Bundle

json
{
  "manifest": {
    "moduleKey": "prilog-project",
    "featureFlag": "project",
    "permissions": ["spaces:read", "files:read", "files:write"]
  }
}

Beim Install sucht installItem die moduleRegistration mit dem passenden manifest.featureFlag und legt eine tenant_module_installation an. Voraussetzung: das Modul muss schon im Backend bekannt sein (also moduleRegistration-Eintrag existiert). Drittanbieter-Apps mit eigenem Code-Bundle brauchen Sandbox-Architektur — siehe "Was bewusst (noch) nicht geht" weiter unten.


Endpoints

Tenant-Sicht (/api/platform/v1/marketplace/*, JWT-Auth)

MethodPathZweck
GET/marketplace/items?type=flow|app[&category=...]Catalog der published Items + installed-Flag
GET/marketplace/items/:idDetail mit Bundle
POST/marketplace/items/:id/installinstalliert Item, legt ggf. Stripe-Sub an
GET/marketplace/subscriptionsWas hat dieser Tenant installiert
DELETE/marketplace/subscriptions/:idUninstall (Stripe-Sub gecancelt, Template/Modul-Daten bleiben in Karenz)

Public-Submission-Flow (/api/public/marketplace/*, kein Login)

MethodPathZweck
POST/marketplace/submissionsVendor reicht Bundle ein (Rate-Limit 5/Std/IP)
GET/marketplace/submissions/:idVendor checkt Status (pending/approved/rejected/needs-changes)

Admin (/api/admin/marketplace/*, Admin-JWT)

MethodPathZweck
GET/marketplace/vendorsVendor-Liste
POST/marketplace/vendorsVendor + Stripe-Connect-Account anlegen
POST/marketplace/vendors/:id/onboarding-linkHosted-Onboarding-URL (einmalig)
POST/marketplace/vendors/:id/syncStatus manuell von Stripe ziehen
GET/marketplace/itemsAlle Items (auch draft/archived)
PATCH/marketplace/items/:idStatus / Pricing / Vendor aendern → loest Stripe-Price-Rotation aus
GET/marketplace/submissions[?status=...]Submission-Liste
GET/marketplace/submissions/:idDetail
POST/marketplace/submissions/:id/approveerzeugt marketplace_items + Stripe-Price
POST/marketplace/submissions/:id/reject(mit Begruendung)
POST/marketplace/submissions/:id/request-changes(mit Begruendung)

Lifecycle-Flows

Install (Flow, kostenlos)

Tenant click "Installieren"
  → POST /marketplace/items/:id/install
  → installItem() ruft importTemplateForTenant()
    → 2-Pass-Component-Import (Pass 1: ohne groupId; Pass 2: groupId-Mapping)
    → Edges nach idMap
  → tenant_marketplace_subscriptions upsert (status=active, installedTemplateId=<neue tplId>)
  → Web-Client navigiert zu /flows/:installedTemplateId

Install (Flow oder App, kostenpflichtig + Vendor)

Tenant click "Installieren"
  → Confirm-Dialog mit Preis ("X €/Monat")
  → POST /marketplace/items/:id/install
  → installItem() prueft:
    - Vendor.stripeAccountStatus === 'active' && chargesEnabled? sonst VENDOR_INACTIVE
    - Tenant.stripeCustomerId != null? sonst NO_STRIPE_CUSTOMER
  → ensureItemStripePrice(itemId)
    - Erstellt Stripe Product + Price wenn nicht da, persistiert IDs
    - flat-monthly: recurring/month, licensed
    - per-active-user: recurring/month, metered, sum aggregation
  → stripe.subscriptions.create({
      customer: tenant.stripeCustomerId,
      items: [{ price: priceId }],
      transfer_data: { destination: vendor.stripeAccountId },  // NUR mit Vendor
      application_fee_percent: vendor.applicationFeePercent
    })
  → Bundle-spezifische Aktion (Flow: importTemplateForTenant; App: tenant_module_installation upsert)
  → tenant_marketplace_subscriptions speichert stripeSubscriptionId

Uninstall

Tenant click Trash-Icon
  → confirm() ("Bezahl-Item wird sofort gekuendigt")
  → DELETE /marketplace/subscriptions/:id
  → uninstall():
    - if stripeSubscriptionId: stripe.subscriptions.cancel()
    - if app: tenant_module_installations.deactivated (30d-Karenz via Modul-Lifecycle-Cron)
    - if flow: kopiertes Template bleibt — Tenant darf weiternutzen
    - tenant_marketplace_subscriptions.status = 'cancelled'

Submission-Review

Vendor → POST /api/public/marketplace/submissions
  → validateSubmission() (Stufe A: Schema, Bundle-Format, Pricing-Plausibilitaet)
  → marketplace_submissions.status = 'pending'
  → Email an Vendor: "Submission eingegangen, Review innerhalb 2 Werktagen"
  → Admin sieht im Submissions-Tab (mit Pending-Counter-Badge)

Admin oeffnet Review-Modal:
  - Sieht Beschreibung, Vendor-Status, Pricing, Bundle (raw JSON)
  - Drei Aktionen mit Notes-Feld:
    - Approve → erzeugt marketplace_items (slug aus Name + Konflikt-Suffix)
                → ensureItemStripePrice() wenn paid
                → Email an Vendor: "Live im Store"
    - Aenderungen anfragen → status='needs-changes' + Email
    - Reject → status='rejected' + Email (Begruendung Pflicht)

Stripe-Integration

Setup-Voraussetzungen

Tenant hat Pro-Subscription:
  - tenant.stripeCustomerId != null
  - via existing Pro-Onboarding (3 €/Aktiv-User/Monat)

Vendor wurde angelegt:
  - createVendor(): stripe.accounts.create({ type: 'express', country: 'DE', email })
  - vendor.stripeAccountId gesetzt, stripeAccountStatus = 'pending'

Vendor durchlaeuft Onboarding:
  - Admin generiert Hosted-Link via createOnboardingLink()
  - Vendor: KYC + IBAN bei Stripe
  - Webhook 'account.updated' → syncVendorStatus() schreibt charges_enabled / payouts_enabled
  - status='active' sobald beides true

Pricing-Modell-Mapping

Item-PricingStripe-PriceCron-Aufwand
null (kostenlos)kein Price
flat-monthly (z.B. 5 €/Mt)recurring/month, licensed— (Stripe billed automatisch)
per-active-user (z.B. 0,50 €/User/Mt)recurring/month, metered, summarketplace-monthly-billing Cron

Per-Active-User-Cron

Schedule: 30 6 1 * *  (1. um 06:30 UTC, 30 min nach Pro-Cron)
Job: marketplace-monthly-billing
Was passiert:
  - findet alle aktiven Subs mit item.priceModel='per-active-user'
  - countActiveUsers(tenantId, lastMonth) — Wiederverwendung des Pro-Counters
  - stripe.subscriptionItems.createUsageRecord({ quantity, action: 'set' })

Webhook-Handler

POST /api/webhooks/stripe (existing) verteilt:

EventHandler
customer.subscription.created/updated (mit metadata.tenantId)freemium-billing.handleStripeSubscriptionEvent
customer.subscription.deleted (mit metadata.marketplaceItemId)Marketplace-Sub als cancelled markieren
customer.subscription.deleted (sonst)Light-Order-Suspend (existing)
account.updated (Vendor-Connect)syncVendorStatus → vendors.charges_enabled / payouts_enabled

Vendor-Auszahlung

Stripe wickelt selbststaendig ab via transfer_data.destination:

Tenant zahlt 10,00 € → Stripe Customer
  ├─ application_fee 30 % = 3,00 € → Prilog-Account
  └─ Rest 7,00 € → Vendor Connect-Account → woechentliche Auszahlung an IBAN

Refunds, Tax (KStG/Umsatzsteuer), 1099-Form: alles Stripe.


Bootstrap & Seeds

bootstrapMarketplace() laeuft beim Server-Start (idempotent):

  • Flow-Seeds (seeds/flow-seeds.ts): 5 vorgefertigte Anleitungen — notfall-brand, onboarding, faq, wiedereinstieg, klassenfahrt. Werden published mit vendorName='Prilog'.
  • App-Seeds (seeds/app-seeds.ts): 1 App — project-module. Wird nur published wenn entsprechende moduleRegistration mit dem featureFlag existiert (sonst Skip).

Manuell archivierte Items (status='archived' im Admin) werden NIE ueberschrieben.


Code-Struktur

prilog-backend-api/src/marketplace/
├── bootstrap.ts                    # Boot-Hook fuer Seeds
├── seeds/
│   ├── flow-seeds.ts               # 5 Flow-Templates
│   └── app-seeds.ts                # 1 App
├── import-template.ts              # Geteilte Logik mit POST /templates/import
├── marketplace.service.ts          # listPublishedItems, installItem, uninstall
├── stripe-price.service.ts         # ensureItemStripePrice, rotateItemStripePrice
├── stripe-connect.service.ts       # createVendor, createOnboardingLink, syncVendorStatus
├── submission.service.ts           # validateSubmission, approve/reject/requestChanges + Email
└── marketplace-billing.service.ts  # per-active-user Cron-Logik

prilog-backend-api/src/routes/
├── platform-v1/marketplace.router.ts        # Tenant-API
├── admin/marketplace.router.ts              # Admin-API (Vendors, Items, Submissions)
└── public/marketplace-submission.router.ts  # Public-Submission

prilog-web-client/src/features/
├── flows/marketplace-gateway.ts             # API-Client
└── settings/sections/plugins-section.tsx    # Plugin-Store-UI (Tab in Settings)

prilog-admin/src/app/marketplace/
└── page.tsx                                 # 3-Tab-UI (Submissions/Items/Vendors)

Was bewusst (noch) nicht geht

Drittanbieter-Apps mit eigenem Backend-Code

App-Items aktivieren heute nur Module die schon im Prilog-Backend existieren. Echte Drittanbieter-Apps mit eigenem Code-Bundle (z.B. ZIP mit JS-Dateien) brauchen eine Sandbox-Architektur (V8-Isolates oder WebWorker oder Container-Isolation pro App). Das ist Wochen Arbeit, nicht Tage — aufschieben bis erster realer Drittanbieter ankommt.

Vendor-Self-Service-Submission im Web

Heute reichen Vendors via POST /api/public/marketplace/submissions ein (curl, eigenes Tool, oder ein Mini-Onboarding-UI das spaeter gebaut werden kann). Eine vollwertige Vendor-Konsole (Login, Bundle-Upload-Wizard, Erloese-Sicht, History) ist eigene Arbeit — heute reicht der Email-Submission-Flow.

Vendor-Erloese-Dashboard

Vendor sieht heute nur via Stripe-Express-Dashboard (Stripe-Hosted) seine Auszahlungen. Eine Prilog-eigene Sicht "wer hat dein Plugin installiert, welche Erloese" waere ein Phase 2c-Feature.

Versions-Updates fuer schon installierte Items

Heute bekommt ein Tenant der v1 installiert hat NICHT automatisch v2 wenn Vendor das Item updated. Das wuerde eine Update-Notification + Migrate-Logik brauchen (Schema-Diff, Conflict-Resolution). Defer'd bis es Bedarf gibt.

Code-Bundle-Storage in S3

Aktuell stehen alle Bundles inline in marketplace_items.bundle (JSONB). Fuer Flows reicht das (paar KB). Wenn App-Code-Bundles (ZIPs, mehrere MB) kommen, braucht es S3-Storage — Schema ist vorbereitet (kann bundle.codeBundleUrl werden).


Tester-Workflow

Eigenes Premium-Plugin

  1. admin.prilog.chat/marketplace → Items-Tab.
  2. Ein Item editieren, Pricing auf "Flat / Monat / 5 €" setzen, speichern.
  3. Im Web-Client als Tenant: Settings → Plugin-Store → Install → Confirm-Dialog mit Preis.
  4. Wenn Tenant Pro-Sub hat: Stripe-Subscription wird angelegt. Dashboard zeigt sie.

Vendor-Onboarding

  1. admin.prilog.chat/marketplace → Vendors-Tab → "Vendor anlegen".
  2. Name, Email, Land. Submit.
  3. Auf neuem Vendor "Onboarding-Link" → oeffnet Stripe-Hosted-KYC im neuen Tab → Vendor durchlaeuft Onboarding.
  4. Stripe Webhook synct status zurueck. Admin "Sync"-Button als Fallback wenn Webhook nicht ankommt.

Submission

bash
curl -X POST https://api.prilog.chat/api/public/marketplace/submissions \
  -H 'Content-Type: application/json' \
  -d '{
    "itemType": "flow",
    "name": "Mein Test-Flow",
    "description": "Was er tut",
    "iconEmoji": "🎯",
    "category": "organization",
    "vendorName": "Acme Corp",
    "vendorEmail": "x@example.com",
    "priceModel": "flat-monthly",
    "priceCents": 500,
    "bundle": {
      "templateExport": {
        "format": "prilog.process-engine/v1",
        "template": { "appKind": "guide", "name": "Test", "description": "" },
        "components": [],
        "edges": []
      }
    }
  }'

→ Vendor bekommt Confirmation-Email. Admin sieht im Submissions-Tab (Pending-Badge im Sidebar). Approve erzeugt das Item.


Anschlussfaehig an