Payment Accounts
TL;DR : un
payment_accountreprésente UN compte Stripe ou PayPal connecté à Trackily. Multi-compte natif : tu peux avoir 3 comptes Stripe et 2 PayPal en parallèle, et choisir lesquels une landing utilise. Les clés sont chiffrées at-rest. Connexion possible manuelle (paste desk_live_…) ou OAuth (un clic, Phase 18 — voir stripe-connect.md).

Concept
payment_accounts est la table qui stocke les credentials de tes processeurs de paiement. Une ligne = un compte chez un provider (Stripe LLC, PayPal, demain Mollie ou Square). Le système est délibérément multi-compte parce qu'il n'est pas rare qu'un opérateur Trackily :
- ait un compte Stripe par marque ou par pays (pour des raisons fiscales) ;
- teste un compte Stripe en mode
testà côté de son comptelive; - route certaines landings sur un sous-compte Stripe Connect d'un partenaire ;
- garde PayPal en backup pour les visiteurs sans CB.
Chaque compte est indépendant : ses clés vivent dans sa propre ligne, chiffrées at-rest via le mécanisme CIPHER_TABLES (clé maître = SECRETS_MASTER_KEY).
Anatomie d'un compte (payment_accounts)
| Champ | Type | Rôle |
|---|---|---|
id |
SERIAL | identifiant interne |
name |
TEXT | nom libre ("Stripe Pro EU", "PayPal backup") |
provider |
TEXT | stripe / paypal (string ouvert pour futurs ajouts) |
mode |
TEXT | live / test |
api_key |
TEXT (chiffré) | publishable / client_id |
api_secret |
TEXT (chiffré) | secret_key / client_secret |
webhook_secret |
TEXT (chiffré) | whsec_… pour Stripe (vide pour PayPal) |
is_active |
BOOLEAN | flag — désactive le compte sans le supprimer |
is_default |
BOOLEAN | flag — compte par défaut pour son provider |
metadata |
JSONB | extension |
oauth_user_id |
TEXT | acct_xxx Stripe (Phase 18 OAuth) |
oauth_access_token |
TEXT (chiffré) | sk_live_… scopé via OAuth |
oauth_refresh_token |
TEXT (chiffré) | rarement utilisé pour Stripe Standard, conservé pour Plus tard |
oauth_scope |
TEXT | read_write typiquement |
oauth_livemode |
BOOLEAN | mirror de mode pour les comptes OAuth |
oauth_connected_at |
TIMESTAMPTZ | date de connexion OAuth |
created_at / updated_at |
TIMESTAMPTZ | audit |
Mapping par provider
| Provider | api_key |
api_secret |
webhook_secret |
|---|---|---|---|
| Stripe | publishable key (pk_live_… ou pk_test_…) |
secret key (sk_live_… ou sk_test_…) |
webhook signing secret (whsec_…) |
| PayPal | REST client_id |
REST client_secret |
(non utilisé) |
Chiffrement at-rest
payment_accounts est dans le set CIPHER_TABLES. Le wrapper findById / findAll / createPaymentAccount / updatePaymentAccount chiffre / déchiffre automatiquement les colonnes sensibles (api_secret, webhook_secret, oauth_access_token, oauth_refresh_token) en lecture / écriture.
Concrètement :
- Tu colles
sk_live_xxxdans le formulaire admin → le helperencryptRowSecretschiffre avant l'INSERT → la base contient le ciphertext. - À chaque création de Stripe Checkout Session, le helper appelle
findById('payment_accounts', id)qui décrypte transparent → la requête versapi.stripe.comreçoit le secret en clair. - Si tu perds
SECRETS_MASTER_KEY, tu perds l'accès aux clés. Backup-la ailleurs que dans le.env.
Comment Trackily choisit le compte au checkout
Le visiteur cliquant sur "Pay with Stripe" sur ta landing :
- La landing a une colonne
payment_account_ids(JSONB array). Si elle contient[3, 7], le visiteur peut payer via le compte 3 OU le compte 7. - Si l'array a un seul élément → ce compte est utilisé.
- Si l'array a plusieurs comptes → l'UI affiche un sélecteur (rarement utile au visiteur final, plus pour le multi-brand).
- Si l'array est vide → fallback sur
is_default=truedu provider. - Si aucun n'a
is_default→ erreur "No payment account configured".
Pour les pages produit standalone /p/<slug>, c'est native_products.default_payment_account_ids qui prend le relais (puisqu'il n'y a pas de landing).
Effective payment account (OAuth vs manual)
Le code applicatif utilise un helper :
function effectivePaymentAccount(row) {
if (!row) return null;
// Si OAuth → on remplace api_secret par oauth_access_token côté API.
if (row.oauth_user_id && row.oauth_access_token) {
return {
...row,
api_secret: row.oauth_access_token,
api_key: row.api_key || row.oauth_publishable_key,
};
}
return row;
}
Pratique : du point de vue du reste du code (createCheckoutSession, refund…), un compte OAuth est indiscernable d'un compte manuel. Le oauth_access_token est un sk_live_… scopé à l'account_id du merchant.
Comment faire (UI + MCP)
Connexion manuelle (paste de clés)
C'est la voie historique. Toujours dispo si tu n'as pas configuré Stripe Connect côté plateforme.
- Settings → Payments (ou
/admin#commerce/payment-methods). - Clique + Add manually.
- Modal :
- Account name — "Stripe Pro EU".
- Provider — Stripe ou PayPal.
- Mode — Live ou Test.
- Publishable key (Stripe) —
pk_live_xxxoupk_test_xxx. Sans c'est pas grave si tu n'utilises pas le SDK front (les clients sont redirigés vers Stripe Checkout qui n'en a pas besoin). - Secret key —
sk_live_xxxousk_test_xxx. - Webhook secret (Stripe) —
whsec_xxx, à récupérer après avoir créé le webhook côté Stripe.
- Save → la ligne
payment_accountsest créée, secrets chiffrés.
Étape webhook (manuelle) : dans le dashboard Stripe → Developers → Webhooks → Add endpoint :
- URL :
https://ton-instance.com/webhook/stripe/native-pending - Events :
checkout.session.completed,payment_intent.succeeded,payment_intent.payment_failed,customer.subscription.updated,customer.subscription.deleted - Récupère le
whsec_…→ colle-le dans Trackily.
Connexion OAuth (Phase 18, recommandé)
Si la plateforme (trackily.online ou ton instance self-hosted) a configuré son commerce_stripe_oauth_client_id + client_secret, tu vois un bouton Connect Stripe sur la page Settings → Payments. Un clic :
- Tu es redirigé vers Stripe.
- Tu te connectes avec ton compte Stripe.
- Tu autorises Trackily à utiliser ton compte (scope
read_write). - Tu reviens sur Trackily, la ligne
payment_accountsest créée automatiquement (avec un webhook auto-créé côté Stripe). - Tu n'as rien copié-collé.
Voir stripe-connect.md pour le walkthrough complet.
Via MCP / admin REST
Pas d'outil MCP dédié à la création de payment_accounts (volontairement — c'est trop sensible pour qu'un agent puisse créer un compte de paiement sans confirmation visuelle humaine sur les credentials). Tu passes par l'admin REST :
POST /admin/api/payment-accounts
Body: {
"name": "Stripe Pro EU",
"provider": "stripe",
"mode": "live",
"api_key": "pk_live_xxx",
"api_secret": "sk_live_xxx",
"webhook_secret": "whsec_xxx",
"is_active": true,
"is_default": true
}
PUT /admin/api/payment-accounts/:id
Body: { "is_active": false }
DELETE /admin/api/payment-accounts/:id
Les endpoints sont protégés par authMiddleware + requirePermission('settings', 'write').
Mode live vs test
| live | test | |
|---|---|---|
| Endpoints Stripe | api.stripe.com (production) | api.stripe.com (mêmes endpoints, scopés via le sk_test_) |
| Argent réel | oui | non — carte de test 4242 4242 4242 4242 |
| Visible dans le dashboard Stripe | oui | onglet "Test data" |
| Pour quoi faire | production | tests de bout-en-bout, démos |
Bonne pratique : avoir un compte test permanent et passer toutes tes nouvelles landings dessus avant de switcher sur le live. Tu peux flip un compte de live à test (et inversement) en éditant le champ mode — mais tu dois aussi remplacer la api_secret correspondante (les clés sk_live_ et sk_test_ sont différentes).
Exemples concrets
1. Setup minimal : 1 Stripe live + 1 PayPal live
Stripe Pro EU (provider=stripe, mode=live, is_default=true)
PayPal Sauvegarde (provider=paypal, mode=live, is_default=true)
Sur chaque landing, payment_account_ids = [] → fallback sur les defaults. Le visiteur voit deux boutons : "Pay with Stripe" + "Pay with PayPal".
2. Multi-brand : 3 comptes Stripe
Stripe Brand A (provider=stripe, mode=live, is_default=false)
Stripe Brand B (provider=stripe, mode=live, is_default=false)
Stripe Brand C (provider=stripe, mode=live, is_default=false)
Sur chaque landing de la brand A : payment_account_ids=[1]. Brand B : [2]. Etc. Aucun is_default (pour éviter qu'une landing oublieuse pioche dans le mauvais compte).
3. Setup test + live côte à côte
Stripe Test (provider=stripe, mode=test, is_default=false)
Stripe Live (provider=stripe, mode=live, is_default=true)
Tu duppliques une landing live → tu lui assignes payment_account_ids=[<id du test>] → tu testes le checkout end-to-end avec une carte 4242, sans risquer de polluer la prod.
Sécurité
- Ne commit jamais ton
.envqui contientSECRETS_MASTER_KEY. - Ne paste jamais un
sk_live_dans Slack / Notion / un repo. Si tu l'as fait, rotate immédiatement côté Stripe Dashboard → API keys → Roll key. - Restreins les scopes côté Stripe — le scope
read_writeest nécessaire pour OAuth, mais tu peux créer un compte API restreint manuellement avec uniquement les permissions Charges/Refunds/PaymentIntents/CheckoutSessions/Webhooks si tu veux du locked-down. - Audit ton historique des refunds via
order_transactions(kind='refund'). Une explosion de refunds depuis un seul compte est un signal de fraude. - Tourne les clés tous les 12 mois — édite la ligne avec la nouvelle clé, ne supprime pas (les commandes historiques gardent leur
payment_account_id).
Erreurs courantes
- "No active key on Stripe account" — la
api_secretn'a pas été setté, ou la décryption a échoué (mauvaiseSECRETS_MASTER_KEYaprès restore). - "Webhook signature verification failed" — le
webhook_secretconfiguré dans Trackily ne match pas celui de l'endpoint Stripe. Lewhsec_…est différent pour chaque endpoint Stripe — vérifie le bon. - "Account is inactive" —
is_active=false. Toggle dans l'UI. - "No payment account configured for this landing" — la landing a
payment_account_ids=[]ET aucun compte n'ais_default=truepour ce provider. Définis un default ou attache au moins un compte à la landing. - "Stripe in test mode but I'm getting real charges" — vérifie que la
api_secretest biensk_test_xxxet passk_live_xxx. Le champmode='test'n'a aucun effet runtime — c'est juste un label. C'est laapi_secretqui décide pour de vrai. - "OAuth connect a échoué, le webhook n'a pas été créé" — le webhook auto-create est best-effort. Si ça a foiré, configure-le à la main dans le dashboard Stripe (URL
/webhook/stripe/native-pending, eventscheckout.session.completedau minimum) et colle lewhsec_…dans le compte via Edit. - "Disconnect d'un compte OAuth, mais le compte reste en base" — c'est attendu. Trackily clear les colonnes
oauth_*mais garde la ligne pour que les commandes historiques conservent leurpayment_account_id. Le compte passe enis_active=falseautomatiquement.
Voir aussi
- stripe-connect.md — la connexion OAuth en un clic.
- orders.md —
orders.payment_account_idet comment il est utilisé pour les refunds. - refunds.md — la pipeline qui pioche dans le compte pour appeler l'API refund.
- Admin UI → Settings — page Settings complète.