Connecteurs

Un connecteur relie Chorus Pay à un logiciel externe : un ERP qui crée vos factures (comme Pennylane ou Dolibarr) ou une boutique en ligne qui envoie ses commandes (comme Shopify). N'importe qui peut en créer un - c'est un simple module JavaScript, décrit par un contrat public.

Comment ça marche

Deux notions à distinguer :

  • Le catalogue : le code d'un connecteur existe une seule fois, versionné (comme un module PrestaShop dans sa marketplace).
  • L'installation : chaque fournisseur installe un connecteur pour son propre compte, avec sa propre configuration (ses clés API, sa boutique…). Vous gérez vos installations dans Paramètres → Connecteurs → Mes connecteurs : activation par interrupteur, un seul ERP et une seule boutique actifs à la fois, Chorus Pro toujours actif (c'est le socle du service).

Un connecteur ne voit que les données du fournisseur chez qui il est installé : chaque appel passe par une interface fournie par Chorus Pay (le ctx) qui filtre tout par compte. Il déclare aussi à l'avance les domaines internet qu'il a le droit d'appeler - tout le reste est refusé.

// Tout ce qu'un connecteur peut faire passe par le "ctx" :
const config  = await ctx.config.get();          // sa configuration (déchiffrée)
const payLink = await ctx.payLinks.get(id);      // les devis DU fournisseur uniquement
await ctx.invoices.create({ ... });              // créer une facture chez CE fournisseur
await ctx.files.saveInvoicePdf({ ... });         // stocker un PDF de facture
await ctx.kv.set("ma-cle", { ... });             // son petit espace de stockage privé
await ctx.events.emit({ type: "invoice.created", ... });
const res = await ctx.http.fetch({ url: "https://api.mon-logiciel.com/..." });

Référence du ctx

Chaque méthode et ce qu'elle retourne. Tout est asynchrone ; les données échangées sont du JSON ou des Buffer.

// ── ctx.config ──────────────────────────────────────────────
await ctx.config.get()                    // → objet config (secrets déchiffrés)
await ctx.config.update({ token: "..." }) // fusionne (secrets re-chiffrés)

// ── ctx.http ────  HTTPS, hôte ∈ manifest.allowedDomains ─────
await ctx.http.fetch({
  url, method?, headers?, body?,          // body: objet JSON | string | Buffer
  responseType?: "json" | "text" | "buffer",
})  // → { status, ok, data, headers, durationMs }   (lève ConnectorHttpError sur échec)

// ── ctx.payLinks ────  toujours scopé au fournisseur ─────────
await ctx.payLinks.get(payLinkId)                       // → PayLinkDto | null
await ctx.payLinks.create({ description, items, ... })  // → { id, quote_number, amount, url, expires_at }
await ctx.payLinks.findActiveByMetadata(source, key, v) // → { id, quote_number, amount, expires_at } | null
await ctx.payLinks.sendQuoteEmail(payLinkId)            // → { success, error? }
// sendQuoteEmail : pipeline complet du core - PDF (généré si besoin), email
// au client (variante "renseignez votre SIRET" si le pay link n'en a pas),
// events pay_link.sent + quote_email_sent, gates boutique onQuoteConfirmed.

// ── ctx.invoices ────
await ctx.invoices.findLatestByPayLink(payLinkId, { withErpId? })  // → InvoiceDto | null
await ctx.invoices.create({ payLinkId, amount, currency, erpReference?, erpInvoiceId? })  // → { invoiceId }

// ── ctx.files ────
await ctx.files.saveInvoicePdf({ invoiceId, payLinkId, pdf: Buffer, filename })  // → fileId | null
await ctx.files.download(fileId)          // → Buffer

// ── ctx.suppliers / events / oauth / kv ──────────────────────
await ctx.suppliers.getProfile()          // → { id, name, trade_name, siret, email }
await ctx.events.emit({ type, entityType, entityId, description, data? })
await ctx.oauth.getAccessToken()          // → string | null  (rafraîchi automatiquement)
await ctx.kv.get(key) / set(key, value) / delete(key)   // stockage privé de l'installation
ctx.logger.info(data, message)            // debug | info | warn | error

// ── ctx.webhooks ────  webhooks sortants POSSÉDÉS par l'installation ──
// Délivrés par Chorus Pay (retries + signature HMAC X-Chorus-Pay-Signature).
// Max 5 par installation ; URL https:// ; le fournisseur les voit avec un
// badge "Géré par <connecteur>" (modification manuelle = avertissement).
await ctx.webhooks.create({ url, events, description? })  // → ManagedWebhook (id, secret whsec_…)
await ctx.webhooks.list()                                  // → ManagedWebhook[]
await ctx.webhooks.update(id, { url?, events?, enabled?, description? })
await ctx.webhooks.delete(id)
// update ne touche JAMAIS l'auth basic posée à la main ni le secret.

Entrées et sorties de chaque capacité

La signature exacte de tout ce qu'un connecteur peut implémenter - ce que la fonction reçoit, ce qu'elle doit retourner.

// ═══ Catégorie "erp" ════════════════════════════════════════

// REQUISE - créer la facture dans le logiciel, retourner son PDF
invoice.create(ctx, {
  payLink: PayLinkDto,       // le devis (montants en string, TVA par ligne…)
  payLinkId: string,
  invoiceDate: string,       // "AAAA-MM-JJ"
}): Promise<ErpInvoiceResult>
// ErpInvoiceResult = {
//   invoiceId: string,           // "" si échec
//   invoiceNumber: string,
//   invoiceResult: unknown,      // objet renvoyé par l'ERP (ou null)
//   invoicePdfFile: Buffer|null, // le PDF, déposé ensuite sur Chorus Pro
//   s3FileId?: string,
//   error: string | null,        // renseigné ⇒ échec
//   errorDetails?, invoiceDate?
// }
// Contrat : error||!invoiceId ⇒ échec ; !error && !invoiceResult ⇒ déjà déposé.

invoice.downloadPdf?(ctx, { erpInvoiceId: string }): Promise<{ pdf: Buffer|null, error? }>
invoice.syncStatus?(ctx, { erpInvoiceId: string, status: string }): Promise<{ success, error? }>

// ═══ Catégorie "shop" ═══════════════════════════════════════
// Gates BLOQUANTS - répondre { success:true } VITE si le pay link
// ne concerne pas la boutique (regarder ctx.kv d'abord).

shop.onQuoteConfirmed?(ctx, { payLinkId }): Promise<{ success, error? }>
shop.completeOrder?(ctx, { payLinkId }): Promise<{ success, orderStatusUrl?, error? }>
shop.preDeposit?(ctx, { payLinkId, invoiceId }): Promise<{ success, error? }>
//   preDeposit en échec ⇒ le dépôt de la facture est suspendu.

// ═══ Toutes catégories ══════════════════════════════════════
checkConnection(ctx): Promise<{ success: boolean, error?, data? }>
hooks: { "invoice.paid": (ctx, event) => Promise<void>, ... }   // non bloquant
routes: { "POST /cart": (ctx, req) => Promise<{ status, json?|text?, headers? }> }
// req.rawBody = corps BRUT (string) tel que reçu, avant parsing JSON -
// indispensable pour vérifier une signature de webhook calculée sur les
// bytes d'origine (ex. Shopify X-Shopify-Hmac-Sha256).
// ctx.installation.routesBaseUrl = URL publique des routes de l'installation
// (https://…/api/connectors/<id>) - pour auto-enregistrer vos webhooks
// distants (ex. abonnement Shopify orders/create → `${routesBaseUrl}/orders`).
actions: { "resync": (ctx) => Promise<{ success, message? }> }  // boutons config
cron: { "sync": { every: "15m", run: (ctx) => Promise<void> } }

// ═══ OAuth entrant (app externe → Chorus Pay) ═══════════════
// manifest.inboundOauth déclare le client OAuth qu'une app externe (module
// CMS chez le marchand) utilise pour se connecter À Chorus Pay. Le
// consentement du fournisseur installe et active le connecteur.
manifest.inboundOauth: { clientId, name?, scopes, redirectUris, pkce: true }

lifecycle: {
  // Invoqué (BLOQUANT, 15s max) pendant l'échange de token OAuth.
  // DOIT être idempotent : reconnexion ⇒ même webhook, même secret.
  // tokenResponseExtras (strings plates) est mergé dans la réponse JSON du
  // token - c'est ainsi que webhook_secret atteint l'app externe.
  // Un échec n'empêche jamais la délivrance du token.
  onOauthConnected(ctx, { shopUrl?, scopes, clientId })
    : Promise<{ tokenResponseExtras? } | void>,
  // Révocation / désinstallation : nettoyage best-effort (les webhooks
  // possédés sont supprimés par le core).
  onOauthRevoked(ctx, { shopUrl?, clientId }): Promise<void>,

  // ═══ Cycle de vie de l'installation (indépendant de l'OAuth) ═══
  // Usage type : ressources distantes auto-gérées - ex. le webhook
  // orders/create de Shopify, enregistré/retiré sans aucun bouton.
  onInstall(ctx),      // création (config encore vide) - best-effort
  onEnable(ctx),       // activation - BLOQUANT : un throw ANNULE l'activation
  onDisable(ctx),      // désactivation (manuelle ou exclusivité de catégorie)
  onUninstall(ctx),    // juste avant suppression (config/kv encore là)
  onConfigSaving(ctx, { next }),  // AVANT sauvegarde : ctx = ANCIENNE config
                       // (et ancienne allowlist http) - démonter ce que la
                       // nouvelle config ne pourra plus atteindre
  onConfigSaved(ctx),  // config sauvegardée pendant que c'est actif : resync
                       // (échec = simple avertissement, la config reste sauvée)
}

// ═══ Badge « boutique connectée » (déclaratif) ══════════════
// Le connecteur pose une valeur en kv quand sa connexion réussit ; l'UI
// affiche le badge en lisant la kv, sans exécuter de code.
manifest.connectionBadge: { kvKey: "connected_shop" }
await ctx.kv.set("connected_shop", "ma-boutique.myshopify.com")

Exemple : création de facture (ERP)

Le cas le plus courant, de bout en bout : idempotence, appel de l'API externe, enregistrement local, PDF, et retour attendu par le core.

// ERP - création de facture (exemple complet et commenté)
async create(ctx, { payLink, payLinkId }) {
  const { apiKey } = await ctx.config.get();

  // 1) Idempotence : si la facture existe déjà pour ce devis, on la réutilise
  const existing = await ctx.invoices.findLatestByPayLink(payLinkId, { withErpId: true });
  if (existing) {
    return {
      invoiceId: existing.id,
      invoiceNumber: existing.erp_reference ?? "",
      invoiceResult: null,
      invoicePdfFile: null,
      error: null,
    };
  }

  // 2) Créer la facture dans le logiciel externe (via ctx.http, HTTPS + allowlist)
  //    Montants arrondis au centime - Chorus Pro affiche les décimales résiduelles.
  const lines = payLink.items.map((it) => ({
    label: it.description,
    quantity: it.quantity,
    unit_price: Math.round(it.unit_price * 100) / 100,
    vat_rate: it.vat_rate,
  }));
  let res;
  try {
    res = await ctx.http.fetch({
      url: "https://api.mon-logiciel.com/invoices",
      method: "POST",
      headers: { Authorization: `Bearer ${apiKey}` },
      body: { customer: payLink.client_info, lines },
    });
  } catch (e) {
    // ctx.http lève ConnectorHttpError sur échec - on retourne un échec propre
    return { invoiceId: "", invoiceNumber: "", invoiceResult: null,
             invoicePdfFile: null, error: e.message };
  }
  const remote = res.data; // { id, number, ... }

  // 3) Enregistrer la facture locale (mappe erpReference / erpInvoiceId)
  const { invoiceId } = await ctx.invoices.create({
    payLinkId,
    amount: payLink.amount,
    currency: payLink.currency,
    erpReference: remote.number,
    erpInvoiceId: String(remote.id),
  });

  // 4) Récupérer le PDF (responseType: "buffer") et le stocker
  const pdfRes = await ctx.http.fetch({
    url: `https://api.mon-logiciel.com/invoices/${remote.id}/pdf`,
    headers: { Authorization: `Bearer ${apiKey}` },
    responseType: "buffer",
  });
  const pdf = pdfRes.data; // Buffer
  await ctx.files.saveInvoicePdf({ invoiceId, payLinkId, pdf,
    filename: `facture_${remote.number}.pdf` });

  // 5) Retour : le core déposera invoicePdfFile sur Chorus Pro
  return {
    invoiceId,
    invoiceNumber: remote.number,
    invoiceResult: { invoice: remote }, // présent ⇒ le core dépose sur Chorus
    invoicePdfFile: pdf,
    error: null,
  };
}

Exemple : gate boutique (bloquant)

Un gate doit répondre { success: true } très vite quand le pay link ne concerne pas la boutique (on regarde ctx.kv avant tout appel réseau).

// BOUTIQUE - gate preDeposit (bloquant, doit no-op VITE si hors périmètre)
async preDeposit(ctx, { payLinkId }) {
  // Ce pay link vient-il de MA boutique ? (lookup kv d'abord, sans réseau)
  const liaison = await ctx.kv.get(`paylink:${payLinkId}`);
  if (!liaison) return { success: true };   // pas ma boutique → on ne bloque pas

  const { access_token } = await ctx.kv.get("shoptoken");
  try {
    await ctx.http.fetch({
      url: `https://${(await ctx.config.get()).shop}/admin/api/orders/${liaison.order_id}/confirm.json`,
      method: "POST",
      headers: { "X-Shopify-Access-Token": access_token },
    });
    return { success: true };
  } catch (e) {
    // Échec ⇒ le core SUSPEND le dépôt de la facture (réessai manuel ensuite)
    return { success: false, error: e.message };
  }
}

Exemple : hook et route

Un hook réagit à un événement (non bloquant) ; une route reçoit une requête entrante montée sous /api/connectors/….

// HOOK - réagir à un événement (non bloquant)
hooks: {
  "invoice.paid": async (ctx, event) => {
    const liaison = await ctx.kv.get(`paylink:${event.data.pay_link_id}`);
    if (!liaison) return;                     // pas concerné
    ctx.logger.info({ orderId: liaison.order_id }, "marquage commande payée");
    // … appel ctx.http pour taguer la commande côté boutique …
  },
},

// ROUTE - recevoir une requête entrante (ex. panier envoyé par le thème)
routes: {
  "POST /cart": async (ctx, req) => {
    const cart = req.body;                    // JSON déjà parsé
    const created = await ctx.payLinks.create({
      description: cart.title ?? "Commande",
      items: cart.lines.map((l) => ({
        description: l.name, quantity: l.qty,
        unit_price: l.price, vat_rate: 20,
      })),
      source: "shopify_theme",
      metadata: { cart_token: cart.token },
    });
    return { status: 201, json: { pay_link_url: created.url } };
  },
},

Événements disponibles pour les hooks

Les hooks s'accrochent aux mêmes événements que les webhooks - une seule liste, celle du catalogue d'événements Chorus Pay. La clé d'un hook est le type d'événement, à écrire à l'identique.

Attention : les clés de hooks ne sont pas vérifiées à la compilation. Une clé mal orthographiée (ex. invoice.deposied) ne déclenchera jamais le hook, en silence. Le kit de conformité (npm test) signale les clés inconnues.
// Les hooks utilisent EXACTEMENT les mêmes types d'événements que les
// webhooks (une seule source : le catalogue d'événements Chorus Pay).
// La clé du hook doit correspondre au type, au caractère près.

// ── Pay link ────────────────────────────────────────────────
"pay_link.created"            "pay_link.sent"
"pay_link.viewed"             "pay_link.quote_email_sent"
"pay_link.accepted"           "pay_link.rejected"
"pay_link.cancelled"          "pay_link.recycled"
"pay_link.expired"            "pay_link.marked_as_paid"
"pay_link.status_updated"     "pay_link.scheduled_date_updated"
"pay_link.purchase_order_uploaded"
"pay_link.purchase_order_uploaded_by_client"
"pay_link.purchase_order_analysed"

// ── Facture ─────────────────────────────────────────────────
"invoice.created"             "invoice.deposited"
"invoice.deposited_verified"  "invoice.validated"
"invoice.paid"                "invoice.rejected"
"invoice.action_required"     "invoice.deposit_failed"
"invoice.creation.failed"     "invoice.chorus_status_updated"

// ── Identité ────────────────────────────────────────────────
"identity.verified"

Anatomie d'un connecteur

Le cœur d'un connecteur est son manifest : sa carte d'identité déclarative. C'est lui qui génère automatiquement la page de configuration vue par le fournisseur (champs texte, secrets masqués et chiffrés, listes déroulantes, boutons d'action) - aucune interface à coder.

import { defineConnector, cf } from "@chorus-pay/connector-sdk";

export default defineConnector({
  manifest: {
    id: "mon-logiciel",            // identifiant unique, définitif
    name: "Mon Logiciel",
    version: "1.0.0",
    category: "erp",               // "erp" (facturation) ou "shop" (boutique)
    minSdkVersion: "1.0.0",
    description: "Synchronise vos factures avec Mon Logiciel.",

    // Les SEULS domaines que le connecteur a le droit d'appeler
    allowedDomains: ["api.mon-logiciel.com"],

    // La page de configuration, générée automatiquement :
    configFields: [
      cf.secret({ key: "apiKey", label: "Clé API", required: true }),
      cf.select({
        key: "env",
        label: "Environnement",
        options: [
          { value: "sandbox", label: "Test" },
          { value: "production", label: "Production" },
        ],
      }),
    ],
  },

  // Le bouton « Tester la connexion »
  async checkConnection(ctx) {
    const config = await ctx.config.get();
    if (!config.apiKey) return { success: false, error: "Clé API requise" };
    const res = await ctx.http.fetch({ url: "https://api.mon-logiciel.com/me" });
    return { success: res.ok };
  },

  capabilities: {
    invoice: {
      // Appelé quand Chorus Pay doit créer la facture dans votre logiciel
      async create(ctx, { payLink, payLinkId, invoiceDate }) {
        // 1. créer la facture via ctx.http
        // 2. l'enregistrer : ctx.invoices.create({...})
        // 3. récupérer le PDF : ctx.files.saveInvoicePdf({...})
        // ...
        return { invoiceId, invoiceNumber, invoiceResult, invoicePdfFile, error: null };
      },
    },
  },
});

Un connecteur peut aussi embarquer un guide d'installation (champ optionnel manifest.setupGuide, en Markdown) : Chorus Pay affiche alors un lien « Guide d'installation pas à pas » sous la configuration du connecteur. En pratique, on maintient un SETUP.md à la racine du repo et le script de build l'injecte dans le manifest - le rendu est fait par Chorus Pay, sans HTML brut (Markdown standard uniquement).

Référence des capacités par type de connecteur

CapacitéTypeRôle
checkConnectiontous (requis)Le bouton « Tester la connexion » - valide la config contre l'API du logiciel
capabilities.invoice.createerp (requis)Crée la facture dans le logiciel et retourne son PDF (déposé ensuite sur Chorus Pro)
capabilities.invoice.downloadPdferp (optionnel)Re-télécharge le PDF d'une facture existante
capabilities.invoice.syncStatuserp (optionnel)Répercute le statut Chorus Pro (validée, payée, rejetée…) dans le logiciel
capabilities.shop.onQuoteConfirmedshop (optionnel)À l'envoi du devis - ex. créer une commande provisoire dans la boutique
capabilities.shop.completeOrdershop (optionnel)À l'acceptation du client - finalise la commande, peut retourner une URL de redirection
capabilities.shop.preDepositshop (optionnel)Juste avant le dépôt de la facture - un échec suspend le dépôt (bloquant)
hookstousRéactions non bloquantes aux événements (devis accepté, facture payée…)
routestousRequêtes entrantes (ex. panier envoyé par la boutique), montées sur /api/connectors/…
actionstousBoutons de la page de configuration (ex. « Resynchroniser »)
crontousTâches périodiques par installation (ex. sync des statuts toutes les 15 min)
manifest.oauthtousFlux OAuth déclaratif géré par Chorus Pay (jetons stockés chiffrés, rafraîchis automatiquement)
manifest.inboundOauthtousClient OAuth entrant : une app externe (module CMS) se connecte à Chorus Pay ; le consentement installe le connecteur
lifecycle.onOauthConnectedtous (optionnel)À la connexion OAuth entrante - provisionne les webhooks de l'app (ctx.webhooks) et renvoie le secret dans la réponse du token
lifecycle (installation)tous (optionnel)onInstall / onEnable (bloquant) / onDisable / onUninstall / onConfigSaving / onConfigSaved - ressources distantes auto-gérées (ex. webhook Shopify enregistré à l'activation, retiré à la désactivation)
manifest.connectionBadgeboutique (optionnel)Badge « boutique connectée » piloté par une clé kv posée par le connecteur - lu par l'UI sans exécuter de code
manifest.setupGuidetous (optionnel)Guide d'installation marchand (Markdown) affiché par Chorus Pay sous la configuration du connecteur

Créer et publier un connecteur

# 1. Partir du template public (GitHub)
git clone https://github.com/tim-tiret/chorus-pay-connector-template mon-logiciel
cd mon-logiciel && npm install
#    installe @chorus-pay/connector-sdk et @chorus-pay/connector-testkit depuis npm

# 2. Implémenter votre connecteur dans index.ts
import { defineConnector, cf } from "@chorus-pay/connector-sdk";

# 3. Vérifier types + conformité au protocole (sans base de données ni réseau)
npm run typecheck
npm test

# 4. Empaqueter : un fichier zip auto-suffisant
npm run build      # → dist/mon-logiciel-1.0.0.zip

# 5. Le zip est relu, signé et publié par l'équipe Chorus Pay

Le kit de test vérifie automatiquement le respect du contrat : format du manifest, comportement en cas de configuration invalide (retourner une erreur, jamais planter), isolation entre comptes, montants arrondis au centime (Chorus Pro affiche les décimales résiduelles sur la facture !). Le détail complet du contrat est dans le dépôt : doc/connectors/CONNECTOR_SPEC.md et le guide pas-à-pas dans doc/connectors/CREATE_CONNECTOR.md.

Pourquoi les connecteurs sont vérifiés et signés

L'isolation des données (le ctx) protège contre les erreurs d'un connecteur honnête. Mais le code d'un connecteur s'exécute sur les serveurs de Chorus Pay : un module malveillant pourrait tenter de contourner ces garde-fous. C'est pourquoi seuls les connecteurs revus et signés cryptographiquement par Chorus Pay peuvent s'exécuter - un zip modifié ou non signé est refusé, à l'envoi comme au chargement.

Concrètement : vous développez votre connecteur, vous le testez avec le kit de conformité, puis vous le soumettez à Chorus Pay qui le relit, le signe et le publie - dans le catalogue public, ou en connecteur privé visible uniquement par votre compte (pour un logiciel interne, par exemple). L'envoi direct de modules sans relecture est prévu dans une version future, avec une exécution en bac à sable.