Marketplace encaisse centralisé sur System D → besoin de tracer les
virements mensuels aux prestataires tiers. Migration appliquée prod.
Schema :
- Modèle RentalPayoutMark { id, providerId, periodMonth, amount,
reference, paidAt, paidByEmail }. Unique (providerId, periodMonth)
→ 1 mark = 1 mois = 1 virement par provider.
Lib src/lib/payouts.ts :
- monthKey(d) → 1er du mois minuit UTC (clé de période).
- listProviderPayouts({monthsBack=6}) → grid provider × mois avec
bookingsCount + grossAmount (itemsTotal) + commission + netAmount
(gross-commission) + statut paid via RentalPayoutMark. Exclut
System D (commission 0%, géré par l'asso). Statut « payé » lu
depuis les marks. Tri : mois desc puis providerName.
- createPayoutMark (idempotent via findUnique avant insert) +
deletePayoutMark.
Politique : net dû = itemsTotal - commissionAmount (depositTotal
hors flux, collecté par le provider auprès du client). Politique
documentée dans le commentaire en tête de payouts.ts.
/admin/payouts/page.tsx :
- 3 KPIs (À payer / Déjà payé / Mois affichés).
- Une section par mois (6 derniers), tableau provider × CA brut +
commission + net dû + statut.
- MarkPaidForm : bouton « Marquer payé » → form inline (amount
pré-rempli avec net dû, reference optionnelle) → action
markPayoutPaidAction. Statut payé montre amount + ref + bouton
« Annuler marquage ».
Server actions :
- markPayoutPaidAction (admin only, idempotent, audit
admin.payouts/payout.mark + payout.already_marked) → envoie
sendPayoutSent au contactEmail du provider (best-effort).
- unmarkPayoutPaidAction → delete + audit payout.unmark.
Email sendPayoutSent : notification au provider quand un virement est
marqué payé. Inclut amount + reference + lien dashboard.
Sidebar admin gagne entrée « Reversements » sous Activité.
Tests vitest tests/lib/payouts.test.ts (4 cas) : monthKey
normalisation UTC + idempotence + janvier sans bug, formatMonth fr-FR.
Total : 74/74 ✓.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Politique de remboursement v1 :
- > 7 jours du début → FULL (location + caution)
- 1 à 7 jours → PARTIAL_50 (50% location + caution intégrale)
- < 24h ou passé → DEPOSIT_ONLY (caution seulement, pas de remboursement
sur la location)
src/lib/rental-refund.ts (NEW) : computeRentalRefund({startDate,
itemsTotal, depositTotal}) → { itemsRefund, depositRefund, totalRefund,
policy, policyLabel }. Arrondi au centime, support de Decimal.
POST /api/rentals/[id]/cancel :
- Auth multi-rôle : tenant de la booking, RENTAL_PROVIDER nominal ou
CE_MANAGER de l'org du provider, ADMIN. Détecte `cancelledBy` pour
adapter l'email.
- Refuse si status ∉ {PENDING, CONFIRMED} (HANDED_OVER → non
annulable, contacter Karbé).
- Calcule le refund selon la politique.
- Stripe refund best-effort si paymentStatus=SUCCEEDED + stripeSessionId
existante + isStripeConfigured + totalRefund > 0. Retrieve session →
payment_intent → refunds.create. Échec Stripe = audit-logged mais
le flip status continue (l'asso pourra rembourser manuellement).
- Transaction : update RentalBooking (CANCELLED + paymentStatus
REFUNDED si SUCCEEDED sinon FAILED) + delete RentalItemAvailability
(libère stock).
- Audit log rental.cancel avec montants, policy, cancelledBy,
stripeRefundId, stripeRefundError.
- Email best-effort : sendRentalCancelled à tenant + provider (sauf si
provider est le canceller).
src/components/CancelRentalButton.tsx : composant client confirm dialog
inline avec textarea motif (max 500 chars). Branché sur :
- /mes-locations : « Annuler ma location » sur résa PENDING/CONFIRMED
- BookingDecision (utilisé par /espace-prestataire/reservations ET
/espace-ce/materiel/reservations) : remplace l'ancienne mini-confirm
qui flippait juste le status, désormais via la vraie API refund
sendRentalCancelled email : adapté selon cancelledBy ("Vous avez annulé"
/ "<Provider> a annulé" / "L'équipe Karbé a annulé").
tests/lib/rental-refund.test.ts : 8 cas (FULL @ 10+ et 7j, PARTIAL_50,
DEPOSIT_ONLY < 24h et passé, arrondi centime, zéro caution, policyLabel).
Total projet : 70/70 ✓.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Public badge sur fiche carbet :
- carbet-public.ts charge les OrganizationCarbetMembership (org
approuvée uniquement) + expose `organizations: {id,name,slug}[]`.
- /carbets/[slug] affiche « Géré par le CE <name> » sous le header
si au moins 1 org liée.
Invites CE_MEMBER :
- Migration 20260603300000_org_invite_token : OrgInviteToken
(tokenHash, organizationId, email?, createdByUserId, expiresAt,
usedAt). Cascade sur Organization. Index expiresAt + organizationId.
- src/lib/ce-invites.ts : createOrgInviteToken (TTL 14j),
listOrgInviteTokens, getOrgInviteByToken (validité + expiry),
markOrgInviteConsumed, revokeOrgInviteToken. Token = 24 bytes
base64url, hash sha256.
- /espace-ce/membres : liste membres (CE_MANAGER + CE_MEMBER actifs)
+ form de génération de lien (email optionnel = lock email côté
signup) + liste des invitations avec statut actif/consommé/expiré +
bouton révoquer.
- /espace-ce/membres/actions.ts : createInviteAction +
revokeInviteAction. Audit log scope=ce.invite.
- API /api/signup étendue : zod accepte inviteToken, branche dédiée
qui crée User CE_MEMBER + organizationId du token + marquage
usedAt. Vérif email match si email fourni dans le token.
- /inscription?invite=TOKEN : récupère l'invite, pré-affiche org name,
lock email si fourni, masque les fieldsets type de compte (forcé
CE_MEMBER).
CTA marketing :
- /pour-comites-entreprise : section CTA « Créer mon espace CE » sous
le rendu content-pages, conditionnée par plugin ce-management.
Tests vitest (tests/lib/ce-access.test.ts) :
- canManageCarbet : admin always, owner direct, CE_MANAGER via org
match, refus si autre org / pas d'org / TOURIST / pas de membership.
- 9 tests, mocks next-auth + @/auth + @/lib/authorization pour éviter
next/server (incompatible vitest sans setup).
- Total tests projet : 62/62 ✓.
Dashboard /espace-ce : lien vers /espace-ce/membres en bas.
Migration prod appliquée.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>