feat: comptabilite + prestataires + rapport financier + stats dynamiques

Comptabilite (Super Admin) :
- ComptabiliteController avec 7 exports CSV/JSON compatibles SAGE
  (journal ventes, grand livre, FEC, balance agee, reglements,
  commissions Stripe 1.5%+0.25E, couts services)
- Export PDF via ComptaPdf (FPDF) avec bloc legal pre-rempli,
  tableau pagine, champ signature DocuSeal
- Signature electronique DocuSeal + callback + envoi email signe
  avec template dedie (compta_export_signed.html.twig)
- Rapport financier public (RapportFinancierPdf) : recettes par
  service, depenses (Stripe, infra, prestataires), bilan excedent/deficit
- Codes comptables clients EC-XXXX (plus de 411xxx)

Prestataires (Super Admin) :
- Entite Prestataire (raisonSociale, siret, email, phone, adresse)
- Entite FacturePrestataire (numFacture, montantHt, montantTtc,
  year, month, isPaid, PDF via Vich)
- CRUD complet avec recherche SIRET via proxy API data.gouv.fr
- Commande cron app:reminder:factures-prestataire (5 du mois)
- Factures prestataires integrees dans export couts services
- Sidebar Super Admin : entree Prestataires + Comptabilite

Stats (/admin/stats) :
- Cout prestataire dynamique depuis FacturePrestataire
- Fusion Infra + Prestataire en "Cout de fonctionnement"
- Commission Stripe corrigee (1.5% + 0.25E par transaction)

Divers :
- DocuSealService::sendComptaForSignature() + getApi()
- Customer::generateCodeComptable() format EC-XXXX-XXXXX
- Protection double prefixe EC- a la creation client
- Bouton regenerer PDF cache quand advert state=accepted
- Modals sans script inline (data-modal-open/close dans app.js)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-07 23:39:31 +02:00
parent 95d33a9a6d
commit 8b35e2b6d2
215 changed files with 11539 additions and 1402 deletions

21
.env
View File

@@ -56,7 +56,7 @@ STRIPE_WEBHOOK_SECRET_CONNECT=
STRIPE_MODE=test
STRIPE_FEE_RATE=0.015
STRIPE_FEE_FIXED=25
ADMIN_EMAIL=contact@siteconseil.fr
ADMIN_EMAIL=contact@e-cosplay.fr
###> SonarQube ###
SONARQUBE_URL=https://sn.esy-web.dev
@@ -64,12 +64,12 @@ SONARQUBE_BADGE_TOKEN=sqb_bf06d32640147db064c99d2e893ca63a072630d7
SONARQUBE_PROJECT_KEY=crm_siteconseil
###< SonarQube ###
###> SSO SITECONSEIL (Keycloak OIDC) ###
###> SSO E-Cosplay (Keycloak OIDC) ###
OAUTH_KEYCLOAK_CLIENT_ID=crm_siteconseil
OAUTH_KEYCLOAK_CLIENT_SECRET=kh1WBbnEzcEZVriXmU7IaxizChReHmIx
OAUTH_KEYCLOAK_URL=https://auth.esy-web.dev
OAUTH_KEYCLOAK_REALM=master
###< SSO SITECONSEIL (Keycloak OIDC) ###
###< SSO E-Cosplay (Keycloak OIDC) ###
###> Keycloak Admin Service Account ###
KEYCLOAK_ADMIN_CLIENT_ID=crm_siteconseil_admin
@@ -132,3 +132,18 @@ OVH_CUSTOMER=
VAULT_URL=
VAULT_TOKEN=
###< vault ###
###> google-search-console ###
GOOGLE_SEARCH_CONSOLE_KEY=
###< google-search-console ###
###> sentry ###
SENTRY_AUTH_TOKEN=
SENTRY_ORG=
SENTRY_API_URL=https://sentry.io/api/0
###< sentry ###
###> tva ###
TVA_ENABLED=false
TVA_RATE=0.20
###< tva ###

View File

@@ -40,7 +40,7 @@ jobs:
cat > /tmp/discord.json <<DEOF
{
"embeds": [{
"title": "DEPOT CRM SITECONSEIL",
"title": "DEPOT CRM E-Cosplay",
"description": "${COMMIT_MSG_ESC}",
"url": "${COMMIT_URL}",
"color": 16156748,
@@ -50,7 +50,7 @@ jobs:
{"name": "Auteur", "value": "${AUTHOR}", "inline": true},
{"name": "Statistiques", "value": "\`\`\`${FILES_CHANGED_ESC}\`\`\`", "inline": false},
{"name": "Fichiers modifies (${FILE_COUNT})", "value": "\`\`\`${FILES_LIST_ESC}\`\`\`", "inline": false},
{"name": "Applications SITECONSEIL", "value": "[CRM](https://crm.siteconseil.fr) - Plateforme de gestion clients/revendeurs\\n[Signature](https://sign.siteconseil.fr) - Signature electronique DocuSeal\\n[Paiement](https://payment.siteconseil.fr) - Portail de paiement Stripe\\n[Status](https://status.siteconseil.fr) - Page de status des services\\n[Stripe Connect](https://stripe.siteconseil.fr) - Webhooks Stripe et redirection dashboard Connect", "inline": false}
{"name": "Applications E-Cosplay", "value": "[CRM](https://crm.e-cosplay.fr) - Plateforme de gestion clients/revendeurs\\n[Signature](https://sign.e-cosplay.fr) - Signature electronique DocuSeal\\n[Paiement](https://payment.e-cosplay.fr) - Portail de paiement Stripe\\n[Status](https://status.e-cosplay.fr) - Page de status des services\\n[Stripe Connect](https://stripe.e-cosplay.fr) - Webhooks Stripe et redirection dashboard Connect", "inline": false}
],
"footer": {"text": "${REPO_NAME}"},
"timestamp": "${TIMESTAMP}"

View File

@@ -1,4 +1,4 @@
crm.siteconseil.fr {
crm.e-cosplay.fr {
tls {
dns cloudflare cfat_rIHZqzCm9GKK3xVnQDNGfu6J91TseIDdTKeuWSFUdf6ccd31
}
@@ -48,6 +48,6 @@ crm.siteconseil.fr {
}
log {
output file /var/log/caddy/ticket.siteconseil.fr.log
output file /var/log/caddy/ticket.e-cosplay.fr.log
}
}

View File

@@ -304,6 +304,14 @@
job: "docker compose -f /var/www/crm-siteconseil/docker-compose-prod.yml exec -T php php bin/console app:ndd:check --env=prod >> /var/log/crm-siteconseil-ndd-check.log 2>&1"
user: bot
- name: Configure payment reminder cron (daily at 9am)
cron:
name: "crm-siteconseil payment reminder"
minute: "0"
hour: "9"
job: "docker compose -f /var/www/crm-siteconseil/docker-compose-prod.yml exec -T php php bin/console app:payment:reminder --env=prod >> /var/log/crm-siteconseil-payment-reminder.log 2>&1"
user: bot
- name: Configure Meilisearch full reindex cron (weekly Sunday at 4am)
cron:
name: "crm-siteconseil meilisearch reindex"

View File

@@ -5,10 +5,10 @@ MESSENGER_TRANSPORT_DSN=redis://:{{ redis_password }}@redis:6379/messages
SESSION_HANDLER_DSN=redis://:{{ redis_password }}@redis:6379/1
REDIS_CACHE_DSN=redis://:{{ redis_password }}@redis:6379/2
MAILER_DSN={{ mailer_dsn }}
DEFAULT_URI=https://ticket.siteconseil.fr
DEFAULT_URI=https://ticket.e-cosplay.fr
VITE_LOAD=1
REAL_MAIL=1
OUTSIDE_URL=https://ticket.siteconseil.fr
OUTSIDE_URL=https://ticket.e-cosplay.fr
STRIPE_PK={{ stripe_pk }}
STRIPE_SK={{ stripe_sk }}
STRIPE_WEBHOOK_SECRET={{ stripe_webhook_secret }}
@@ -20,12 +20,12 @@ MEILISEARCH_API_KEY={{ meilisearch_api_key }}
SONARQUBE_URL=https://sn.esy-web.dev
SONARQUBE_BADGE_TOKEN={{ sonarqube_badge_token }}
SONARQUBE_PROJECT_KEY=crm_siteconseil
OAUTH_KEYCLOAK_CLIENT_ID=crm_siteconseil
OAUTH_KEYCLOAK_CLIENT_SECRET=kh1WBbnEzcEZVriXmU7IaxizChReHmIx
OAUTH_KEYCLOAK_CLIENT_ID=crm_ecosplay
OAUTH_KEYCLOAK_CLIENT_SECRET=QiksEpHqDCHFPMM9CWb3RHfag31VJfIV
OAUTH_KEYCLOAK_URL=https://auth.esy-web.dev
OAUTH_KEYCLOAK_REALM=master
OAUTH_KEYCLOAK_REALM=e-cosplay
SECRET_ANALYTICS={{ analytics_secret }}
KEYCLOAK_ADMIN_CLIENT_ID=crm_siteconseil_admin
KEYCLOAK_ADMIN_CLIENT_ID=crm-ecosplay-admin
KEYCLOAK_ADMIN_CLIENT_SECRET={{ keycloak_admin_client_secret }}
AWS_PK={{ aws_pk }}
AWS_SECRET={{ aws_secret }}
@@ -33,8 +33,10 @@ AWS_REGION=eu-west-3
CLOUDFLARE_KEY={{ cloudflare_key }}
MAILCOW_URL=https://mail.esy-web.dev
MAILCOW_API_KEY={{ mailcow_api_key }}
WEBHOOK_BASE_URL=https://stripe.siteconseil.fr
WEBHOOK_BASE_URL=https://stripe.e-cosplay.fr
DOCUSEAL_URL=https://signature.esy-web.dev
DOCUSEAL_API={{ docuseal_api }}
DOCUSEAL_WEBHOOKS_SECRET_HEADER=X-Sign
DOCUSEAL_WEBHOOKS_SECRET={{ docuseal_webhooks_secret }}
TVA_ENABLED={{ tva_enabled }}
TVA_RATE={{ tva_rate }}

View File

@@ -2,4 +2,4 @@
127.0.0.1 ansible_user=bot ansible_become=yes ansible_become_method=sudo ansible_connection=local
[production:vars]
deploy_path=/var/www/crm.siteconseil/
deploy_path=/var/www/crm.e-cosplay/

View File

@@ -23,13 +23,15 @@ discord_webhook: https://discord.com/api/webhooks/1419573620602044518/ikAdxWxsrr
esymail_hostname: mail.esy-web.dev
vault_url: https://kms.esy-web.dev
vault_token: CHANGE_ME_IN_PROD
tva_enabled: 'false'
tva_rate: '0.20'
ovh_key: 34bc2c2eb416b67d
ovh_secret: 12239d273975b5ab53318907fb66d355
ovh_customer: 56c387eb9ca4b9a2de4d4d97fd3d7f22
smime_private_key: |
Bag Attributes
localKeyID: 75 15 E3 C2 1D 7B 61 75 99 B9 22 D8 FD A4 19 AC 6B BE 1F 8F
friendlyName: contact@siteconseil.fr
friendlyName: contact@e-cosplay.fr
Key Attributes: <No Attributes>
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC60+PtobUKQsjH

View File

@@ -3,8 +3,8 @@ import { initEntrepriseSearch } from "./modules/entreprise-search.js"
// Membre / Super Admin : mutuellement exclusif
document.addEventListener('DOMContentLoaded', () => {
const memberCheckbox = document.querySelector('input[value="siteconseil_member"]');
const adminCheckbox = document.querySelector('input[value="siteconseil_admin"]');
const memberCheckbox = document.querySelector('input[value="gp_member"]');
const adminCheckbox = document.querySelector('input[value="superadmin"]');
const otherGroupCheckboxes = () =>
[...document.querySelectorAll('input[name="groups[]"]')].filter(cb => cb !== memberCheckbox);
@@ -35,8 +35,8 @@ document.addEventListener('DOMContentLoaded', () => {
confirmModal.className = 'hidden fixed inset-0 z-[100] flex items-center justify-center';
confirmModal.innerHTML = `
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm" id="confirm-overlay"></div>
<div class="relative glass-heavy w-full max-w-md mx-4 overflow-hidden" style="border-radius: 16px;">
<div class="glass-dark text-white px-6 py-4 flex items-center gap-3" style="border-radius: 16px 16px 0 0;">
<div class="relative glass-heavy w-full max-w-md mx-4 overflow-hidden">
<div class="glass-dark text-white px-6 py-4 flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
@@ -46,7 +46,7 @@ document.addEventListener('DOMContentLoaded', () => {
<p id="confirm-message" class="text-sm font-medium text-gray-700 leading-relaxed"></p>
<div class="flex justify-end gap-3 mt-6">
<button type="button" id="confirm-cancel" class="px-5 py-2 glass font-bold uppercase text-xs tracking-wider text-gray-700 hover:bg-gray-900 hover:text-white transition-all">Annuler</button>
<button type="button" id="confirm-ok" class="px-5 py-2 bg-red-600 text-white font-bold uppercase text-xs tracking-wider hover:bg-red-700 transition-all" style="border-radius: 6px;">Confirmer</button>
<button type="button" id="confirm-ok" class="px-5 py-2 bg-red-600 text-white font-bold uppercase text-xs tracking-wider hover:bg-red-700 transition-all">Confirmer</button>
</div>
</div>
</div>`;
@@ -274,9 +274,13 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// ──────── Stripe Payment Modal ────────
initStripePayment();
// ──────── Tab search devis / avis ────────
initTabSearch('search-devis', 'search-devis-results');
initTabSearch('search-adverts', 'search-adverts-results');
initTabSearch('search-factures', 'search-factures-results');
// ──────── Devis lines repeater + drag & drop ────────
initDevisLines();
@@ -287,8 +291,224 @@ document.addEventListener('DOMContentLoaded', () => {
if (refuseBtn && refuseForm) {
refuseBtn.addEventListener('click', () => refuseForm.classList.toggle('hidden'));
}
// ──────── Modal open/close generique (data-modal-open / data-modal-close) ────────
document.querySelectorAll('[data-modal-open]').forEach(btn => {
btn.addEventListener('click', () => {
const modal = document.getElementById(btn.dataset.modalOpen);
if (modal) modal.classList.remove('hidden');
});
});
document.querySelectorAll('[data-modal-close]').forEach(btn => {
btn.addEventListener('click', () => {
const modal = document.getElementById(btn.dataset.modalClose);
if (modal) modal.classList.add('hidden');
});
});
// ──────── Prestataire : recherche SIRET via API entreprise.data.gouv.fr ────────
const siretSearchBtn = document.getElementById('siret-search-btn');
const siretInput = document.getElementById('siret-search-input');
const siretResults = document.getElementById('siret-search-results');
if (siretSearchBtn && siretInput && siretResults) {
siretSearchBtn.addEventListener('click', () => {
const q = siretInput.value.trim();
if (q.length < 3) { siretResults.innerHTML = '<p class="text-xs text-gray-400 p-2">Saisissez au moins 3 caracteres.</p>'; siretResults.classList.remove('hidden'); return; }
siretResults.innerHTML = '<p class="text-xs text-gray-400 p-2">Recherche...</p>';
siretResults.classList.remove('hidden');
fetch('/admin/prestataires/entreprise-search?q=' + encodeURIComponent(q))
.then(r => r.json())
.then(data => {
const results = data.results || [];
if (results.length === 0) {
siretResults.innerHTML = '<p class="text-xs text-gray-400 p-2">Aucun resultat.</p>';
return;
}
siretResults.innerHTML = results.map(r => {
const siege = r.siege || {};
const siret = siege.siret || '';
const nom = r.nom_complet || r.nom_raison_sociale || '';
const adresse = siege.adresse || '';
const cp = siege.code_postal || '';
const ville = siege.libelle_commune || '';
return '<button type="button" class="siret-result-item block w-full text-left px-3 py-2 hover:bg-white/70 border-b border-white/20 transition-all"'
+ ' data-nom="' + nom.replace(/"/g, '&quot;') + '"'
+ ' data-siret="' + siret + '"'
+ ' data-adresse="' + adresse.replace(/"/g, '&quot;') + '"'
+ ' data-cp="' + cp + '"'
+ ' data-ville="' + ville.replace(/"/g, '&quot;') + '">'
+ '<span class="font-bold text-xs">' + nom + '</span>'
+ '<span class="text-[10px] text-gray-400 ml-2">' + siret + '</span>'
+ '<br><span class="text-[10px] text-gray-400">' + adresse + ' ' + cp + ' ' + ville + '</span>'
+ '</button>';
}).join('');
siretResults.querySelectorAll('.siret-result-item').forEach(item => {
item.addEventListener('click', () => {
const form = siretSearchBtn.closest('form');
if (!form) return;
const set = (name, val) => { const el = form.querySelector('[name="' + name + '"]'); if (el) el.value = val; };
set('raisonSociale', item.dataset.nom);
set('siret', item.dataset.siret);
set('address', item.dataset.adresse);
set('zipCode', item.dataset.cp);
set('city', item.dataset.ville);
siretResults.classList.add('hidden');
siretInput.value = '';
});
});
})
.catch(() => {
siretResults.innerHTML = '<p class="text-xs text-red-500 p-2">Erreur lors de la recherche.</p>';
});
});
siretInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); siretSearchBtn.click(); } });
document.addEventListener('click', (e) => { if (!siretResults.contains(e.target) && e.target !== siretInput && e.target !== siretSearchBtn) siretResults.classList.add('hidden'); });
}
});
function initStripePayment() {
const btnStripe = document.getElementById('btn-stripe');
const modal = document.getElementById('stripe-modal');
const overlay = document.getElementById('stripe-modal-overlay');
const closeBtn = document.getElementById('stripe-modal-close');
const payBtn = document.getElementById('stripe-pay-btn');
const errorsEl = document.getElementById('stripe-errors');
const paymentEl = document.getElementById('stripe-payment-element');
if (!btnStripe || !modal || !paymentEl) return;
const pk = btnStripe.dataset.pk;
const intentUrl = btnStripe.dataset.intentUrl;
const successUrl = btnStripe.dataset.successUrl;
const amount = btnStripe.dataset.amount;
if (!pk) return;
const stripe = Stripe(pk);
let elements = null;
let clientSecret = null;
let isProcessing = false;
const showModal = () => modal.classList.remove('hidden');
const hideModal = () => { if (!isProcessing) modal.classList.add('hidden'); };
closeBtn.addEventListener('click', hideModal);
overlay.addEventListener('click', hideModal);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideModal(); });
let currentMethod = 'card';
function openStripeModal(method) {
currentMethod = method;
showModal();
payBtn.disabled = true;
payBtn.textContent = 'Chargement...';
errorsEl.classList.add('hidden');
const bodyPayload = method === 'sepa' ? { method: 'sepa' } : {};
fetch(intentUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bodyPayload),
})
.then(r => r.json())
.then(data => {
if (data.error) {
errorsEl.textContent = data.error;
errorsEl.classList.remove('hidden');
payBtn.textContent = method === 'sepa' ? 'Prelever ' + amount + ' €' : method === 'paypal' ? 'Payer via PayPal ' + amount + ' €' : 'Payer ' + amount + ' €';
return;
}
clientSecret = data.clientSecret;
const elementsOptions = {
clientSecret: clientSecret,
locale: 'fr',
appearance: {
theme: 'stripe',
variables: {
fontFamily: 'Arial, Helvetica, sans-serif',
colorPrimary: method === 'sepa' ? '#2563eb' : '#4f46e5',
borderRadius: '8px',
},
},
};
elements = stripe.elements(elementsOptions);
const paymentElement = elements.create('payment');
paymentEl.innerHTML = '';
paymentElement.mount('#stripe-payment-element');
paymentElement.on('ready', () => {
payBtn.disabled = false;
payBtn.textContent = method === 'sepa' ? 'Prelever ' + amount + ' €' : method === 'paypal' ? 'Payer via PayPal ' + amount + ' €' : 'Payer ' + amount + ' €';
});
paymentElement.on('change', (event) => {
if (event.error) {
errorsEl.textContent = event.error.message;
errorsEl.classList.remove('hidden');
} else {
errorsEl.classList.add('hidden');
}
});
})
.catch(() => {
errorsEl.textContent = 'Erreur de connexion au serveur de paiement.';
errorsEl.classList.remove('hidden');
payBtn.textContent = method === 'sepa' ? 'Prelever ' + amount + ' €' : method === 'paypal' ? 'Payer via PayPal ' + amount + ' €' : 'Payer ' + amount + ' €';
});
}
btnStripe.addEventListener('click', () => openStripeModal('card'));
// Bouton SEPA
const btnSepa = document.getElementById('btn-sepa');
if (btnSepa) {
btnSepa.addEventListener('click', () => openStripeModal('sepa'));
}
payBtn.addEventListener('click', async () => {
if (!clientSecret || !elements || isProcessing) return;
isProcessing = true;
payBtn.disabled = true;
payBtn.textContent = 'Traitement en cours...';
errorsEl.classList.add('hidden');
// confirmPayment gere automatiquement le 3D Secure (3DS)
// Stripe ouvre la modal 3DS dans une iframe si necessaire
const { error } = await stripe.confirmPayment({
elements: elements,
confirmParams: {
return_url: window.location.origin + successUrl + '?method=' + currentMethod,
},
});
// Si on arrive ici, c'est qu'il y a eu une erreur
// (en cas de succes, le navigateur est redirige vers return_url)
if (error) {
if (error.type === 'card_error' || error.type === 'validation_error') {
errorsEl.textContent = error.message;
} else {
errorsEl.textContent = 'Une erreur inattendue est survenue. Veuillez reessayer.';
}
errorsEl.classList.remove('hidden');
}
isProcessing = false;
payBtn.disabled = false;
payBtn.textContent = currentMethod === 'sepa' ? 'Prelever ' + amount + ' €' : 'Payer ' + amount + ' €';
});
}
function initTabSearch(inputId, resultsId) {
const input = document.getElementById(inputId);
const results = document.getElementById(resultsId);
@@ -394,7 +614,62 @@ function initDevisLines() {
addBtn.addEventListener('click', () => addLine());
// Boutons prestations rapides : ajoute une ligne pre-remplie
// Validation : empeche l'envoi si un type est selectionne mais pas le service
const form = document.getElementById('devis-form');
if (form) {
form.addEventListener('submit', (e) => {
const rows = container.querySelectorAll('.line-row');
for (const row of rows) {
const typeSelect = row.querySelector('.line-type');
const serviceSelect = row.querySelector('.line-service-id');
if (!typeSelect || !serviceSelect) continue;
const type = typeSelect.value;
if (!type || type === 'hosting' || type === 'maintenance' || type === 'other' || type === 'ndd' || type === 'website') continue;
// Type avec service obligatoire (esymail) mais pas selectionne — ndd et website autorisent le vide
if (!serviceSelect.value && !serviceSelect.disabled && serviceSelect.options.length > 1) {
e.preventDefault();
serviceSelect.focus();
serviceSelect.classList.add('border-red-500', 'ring-2', 'ring-red-300');
const pos = row.querySelector('.line-pos')?.textContent || '';
alert('Ligne ' + pos + ' : veuillez selectionner le service pour le type "' + typeSelect.options[typeSelect.selectedIndex].text + '".');
return;
}
}
});
}
// Chargement dynamique des services par type
container.addEventListener('change', async (e) => {
if (!e.target.classList.contains('line-type')) return;
const select = e.target;
const row = select.closest('.line-row');
const serviceSelect = row.querySelector('.line-service-id');
const type = select.value;
serviceSelect.innerHTML = '<option value="">— Selectionner le service —</option>';
serviceSelect.disabled = true;
if (!type || type === 'hosting' || type === 'maintenance' || type === 'other') return;
const url = select.dataset.servicesUrl.replace('__TYPE__', type);
try {
const resp = await fetch(url);
const items = await resp.json();
if (items.length > 0) {
items.forEach(item => {
const opt = document.createElement('option');
opt.value = item.id;
opt.textContent = item.label;
serviceSelect.appendChild(opt);
});
serviceSelect.disabled = false;
}
} catch (err) { /* silencieux */ }
});
// Boutons prestations rapides : ajoute une ligne pre-remplie avec type auto
document.querySelectorAll('.quick-price-btn').forEach(btn => {
btn.addEventListener('click', () => {
addLine();
@@ -404,6 +679,18 @@ function initDevisLines() {
lastRow.querySelector('textarea[name$="[description]"]').value = btn.dataset.description || '';
const priceInput = lastRow.querySelector('.line-price');
priceInput.value = btn.dataset.price || '0.00';
// Auto-set le type de service
const lineType = btn.dataset.lineType || '';
if (lineType) {
const typeSelect = lastRow.querySelector('.line-type');
if (typeSelect) {
typeSelect.value = lineType;
// Trigger change pour charger les services du client
typeSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
}
recalc();
});
});
@@ -457,13 +744,38 @@ function initDevisLines() {
try {
const arr = JSON.parse(initial);
arr.sort((a, b) => (a.pos || 0) - (b.pos || 0));
arr.forEach(l => {
arr.forEach(async (l) => {
addLine();
const row = container.querySelector('.line-row:last-child');
if (!row) return;
row.querySelector('input[name$="[title]"]').value = l.title || '';
row.querySelector('textarea[name$="[description]"]').value = l.description || '';
row.querySelector('.line-price').value = l.priceHt || '0.00';
// Pre-select type
const typeSelect = row.querySelector('.line-type');
if (l.type && typeSelect) {
typeSelect.value = l.type;
// Charge les services si type avec serviceId
if (l.serviceId && l.type !== 'hosting' && l.type !== 'maintenance' && l.type !== 'other') {
const serviceSelect = row.querySelector('.line-service-id');
const url = typeSelect.dataset.servicesUrl.replace('__TYPE__', l.type);
try {
const resp = await fetch(url);
const items = await resp.json();
serviceSelect.innerHTML = '<option value="">— Selectionner le service —</option>';
items.forEach(item => {
const opt = document.createElement('option');
opt.value = item.id;
opt.textContent = item.label;
if (item.id === l.serviceId) opt.selected = true;
serviceSelect.appendChild(opt);
});
serviceSelect.disabled = false;
} catch (err) { /* ignore */ }
}
}
});
recalc();
} catch (e) { /* ignore */ }

View File

@@ -1,147 +1,143 @@
@import "tailwindcss";
/* ─── Glass Design System ─── */
/* ─── Neo Brutalist Design System ─── */
:root {
--glass-bg: rgba(255, 255, 255, 0.65);
--glass-bg-heavy: rgba(255, 255, 255, 0.85);
--glass-border: rgba(255, 255, 255, 0.3);
--glass-border-strong: rgba(255, 255, 255, 0.5);
--glass-dark: rgba(17, 24, 39, 0.85);
--glass-dark-heavy: rgba(17, 24, 39, 0.92);
--glass-blur: 16px;
--glass-blur-heavy: 24px;
--brutal-bg: #ffffff;
--brutal-border: 3px solid #111827;
--brutal-border-thin: 2px solid #111827;
--brutal-shadow: 4px 4px 0px #111827;
--brutal-shadow-hover: 6px 6px 0px #111827;
--brutal-shadow-active: 2px 2px 0px #111827;
--brutal-shadow-gold: 4px 4px 0px #b8860b;
--gold: #fabf04;
--gold-light: rgba(250, 191, 4, 0.15);
--gold-glow: rgba(250, 191, 4, 0.4);
--radius: 16px;
--radius-sm: 10px;
--radius-xs: 6px;
--shadow-glass: 0 8px 32px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
--shadow-glass-hover: 0 12px 40px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.06);
--shadow-gold: 0 4px 24px rgba(250, 191, 4, 0.25);
--dark: #111827;
--radius: 0px;
--radius-sm: 0px;
--radius-xs: 0px;
}
/* ─── Animated background ─── */
/* ─── Background ─── */
body.glass-bg {
background: #f0f0f5;
background: #f5f5f0;
background-image:
radial-gradient(ellipse at 20% 50%, rgba(250, 191, 4, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(99, 102, 241, 0.06) 0%, transparent 50%),
radial-gradient(ellipse at 50% 80%, rgba(250, 191, 4, 0.05) 0%, transparent 50%);
background-attachment: fixed;
repeating-linear-gradient(0deg, transparent, transparent 49px, rgba(0,0,0,0.03) 49px, rgba(0,0,0,0.03) 50px),
repeating-linear-gradient(90deg, transparent, transparent 49px, rgba(0,0,0,0.03) 49px, rgba(0,0,0,0.03) 50px);
}
/* ─── Glass panel ─── */
/* ─── Brutalist panels ─── */
.glass {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
background: var(--brutal-bg);
border: var(--brutal-border);
border-radius: var(--radius);
box-shadow: var(--shadow-glass);
box-shadow: var(--brutal-shadow);
}
.glass-heavy {
background: var(--glass-bg-heavy);
backdrop-filter: blur(var(--glass-blur-heavy));
-webkit-backdrop-filter: blur(var(--glass-blur-heavy));
border: 1px solid var(--glass-border-strong);
background: var(--brutal-bg);
border: var(--brutal-border);
border-radius: var(--radius);
box-shadow: var(--shadow-glass);
box-shadow: var(--brutal-shadow);
}
.glass-dark {
background: var(--glass-dark);
backdrop-filter: blur(var(--glass-blur-heavy));
-webkit-backdrop-filter: blur(var(--glass-blur-heavy));
border: 1px solid rgba(255, 255, 255, 0.08);
background: var(--dark);
border: var(--brutal-border);
border-radius: var(--radius);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
box-shadow: var(--brutal-shadow);
color: white;
}
.glass-dark-heavy {
background: var(--glass-dark-heavy);
backdrop-filter: blur(var(--glass-blur-heavy));
-webkit-backdrop-filter: blur(var(--glass-blur-heavy));
border: 1px solid rgba(255, 255, 255, 0.06);
background: var(--dark);
border: 3px solid #000;
color: white;
}
.glass-gold {
background: rgba(250, 191, 4, 0.12);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid rgba(250, 191, 4, 0.3);
background: var(--gold);
border: var(--brutal-border);
border-radius: var(--radius);
box-shadow: var(--shadow-gold);
box-shadow: var(--brutal-shadow-gold);
}
/* ─── Glass buttons ─── */
/* ─── Brutalist buttons ─── */
.btn-glass {
background: var(--glass-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-xs);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
background: var(--brutal-bg);
border: var(--brutal-border-thin);
border-radius: var(--radius);
box-shadow: var(--brutal-shadow);
transition: all 0.1s ease;
cursor: pointer;
&:hover {
background: var(--glass-bg-heavy);
box-shadow: var(--shadow-glass-hover);
transform: translateY(-1px);
box-shadow: var(--brutal-shadow-hover);
transform: translate(-2px, -2px);
}
&:active {
box-shadow: var(--brutal-shadow-active);
transform: translate(2px, 2px);
}
}
.btn-gold {
background: var(--gold);
border: 1px solid rgba(250, 191, 4, 0.6);
border-radius: var(--radius-xs);
box-shadow: var(--shadow-gold);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
border: var(--brutal-border-thin);
border-radius: var(--radius);
box-shadow: var(--brutal-shadow);
transition: all 0.1s ease;
cursor: pointer;
&:hover {
box-shadow: 0 6px 28px rgba(250, 191, 4, 0.4);
transform: translateY(-1px);
box-shadow: var(--brutal-shadow-hover);
transform: translate(-2px, -2px);
}
&:active {
box-shadow: var(--brutal-shadow-active);
transform: translate(2px, 2px);
}
}
.btn-dark {
background: var(--glass-dark-heavy);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-xs);
background: var(--dark);
border: var(--brutal-border-thin);
border-radius: var(--radius);
color: white;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--brutal-shadow);
transition: all 0.1s ease;
cursor: pointer;
&:hover {
background: rgba(99, 102, 241, 0.85);
border-color: rgba(99, 102, 241, 0.4);
transform: translateY(-1px);
background: var(--gold);
color: var(--dark);
box-shadow: var(--brutal-shadow-hover);
transform: translate(-2px, -2px);
}
&:active {
box-shadow: var(--brutal-shadow-active);
transform: translate(2px, 2px);
}
}
/* ─── Glass input ─── */
/* ─── Brutalist input ─── */
.input-glass {
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--radius-xs);
transition: all 0.2s ease;
background: #ffffff;
border: var(--brutal-border-thin);
border-radius: var(--radius);
transition: all 0.1s ease;
&:focus {
outline: none;
background: rgba(255, 255, 255, 0.8);
border-color: var(--gold);
box-shadow: 0 0 0 3px var(--gold-light), var(--shadow-glass);
box-shadow: 3px 3px 0px var(--gold);
}
}
@@ -160,7 +156,7 @@ body.glass-bg {
}
.heading-page {
border-bottom: 2px solid var(--gold);
border-bottom: 4px solid var(--gold);
display: inline-block;
padding-bottom: 0.5rem;
}
@@ -201,15 +197,14 @@ body.glass-bg {
top: 0;
bottom: 0;
z-index: 50;
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: left 0.2s ease;
}
.admin-sidebar.open { left: 0; }
.admin-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
background: rgba(0, 0, 0, 0.5);
z-index: 40;
}
.admin-sidebar.open + .admin-overlay { display: block; }
@@ -223,42 +218,45 @@ body.glass-bg {
gap: 0.75rem;
padding: 0.625rem 0.875rem;
font-size: 0.7rem;
font-weight: 700;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
border-radius: var(--radius-sm);
transition: all 0.2s ease;
letter-spacing: 0.1em;
border-radius: 0;
transition: all 0.1s ease;
color: rgba(255, 255, 255, 0.75);
border-left: 4px solid transparent;
&:hover {
background: rgba(30, 41, 59, 0.9);
background: rgba(255, 255, 255, 0.1);
color: white;
border-left-color: var(--gold);
}
&.active {
background: var(--gold);
color: #111827;
box-shadow: 0 2px 12px rgba(250, 191, 4, 0.3);
border-left-color: #111827;
font-weight: 900;
}
&.active-danger {
background: rgba(220, 38, 38, 0.8);
background: #dc2626;
color: white;
box-shadow: 0 2px 12px rgba(220, 38, 38, 0.3);
border-left-color: #111827;
}
}
/* ─── Scrollbar styling ─── */
.admin-sidebar::-webkit-scrollbar {
width: 4px;
width: 6px;
}
.admin-sidebar::-webkit-scrollbar-track {
background: transparent;
}
.admin-sidebar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
background: rgba(255, 255, 255, 0.2);
border-radius: 0;
}
/* ─── Smooth transitions ─── */
@@ -269,4 +267,9 @@ body.glass-bg {
/* ─── Devis lines drag & drop ─── */
.line-row.dragging { opacity: 0.4; }
.line-row.drag-over { border-top: 2px solid #fabf04; }
.line-row.drag-over { border-top: 4px solid var(--gold); }
/* ─── Brutalist overrides for Tailwind rounded ─── */
.rounded, .rounded-lg, .rounded-xl, .rounded-md, .rounded-sm, .rounded-full {
border-radius: 0 !important;
}

View File

@@ -63,9 +63,9 @@ const renderResult = (e, onSelect) => {
</div>
<div class="text-xs text-gray-400 mt-1">${s.geo_adresse || s.adresse || ''}</div>
${d.nom ? '<div class="text-xs text-gray-400 mt-1">Dirigeant : ' + (d.prenoms || '') + ' ' + d.nom + '</div>' : ''}
${isAsso ? '<div class="text-xs mt-1"><span class="px-1.5 py-0.5 bg-indigo-100 text-indigo-800 font-bold text-[10px] uppercase" style="border-radius:4px;">Association</span></div>' : ''}
${isAsso ? '<div class="text-xs mt-1"><span class="px-1.5 py-0.5 bg-indigo-100 text-indigo-800 font-bold text-[10px] uppercase" >Association</span></div>' : ''}
</div>
<span class="shrink-0 px-2 py-1 text-[10px] font-bold uppercase ${actif ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}" style="border-radius: 6px;">
<span class="shrink-0 px-2 py-1 text-[10px] font-bold uppercase ${actif ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}" >
${actif ? 'Actif' : 'Ferme'}
</span>
</div>`

View File

@@ -96,4 +96,4 @@ nelmio_security:
- auth.esy-web.dev
- challenges.cloudflare.com
- signature.esy-web.dev
- signature.siteconseil.fr
- signature.e-cosplay.fr

View File

@@ -1,9 +1,9 @@
pwa:
asset_compiler: false # Default to true. Will change to false in 2.0.0.
image_processor: 'pwa.image_processor.gd' # Or 'pwa.image_processor.gd'
asset_compiler: false
image_processor: 'pwa.image_processor.gd'
favicons:
enabled: true
src: '%kernel.project_dir%/public/assets/notif.png'
src: '%kernel.project_dir%/public/favicon.png'
serviceworker:
enabled: true
scope: "/"
@@ -11,13 +11,13 @@ pwa:
skip_waiting: true
manifest:
enabled: true
name: "SITECONSEIL"
short_name: "PWA"
name: "E-Cosplay"
short_name: "E-Cosplay"
start_url: "app_home"
display: "standalone"
background_color: "#ffffff"
theme_color: "#4285f4"
categories: ['games','multimedia','social networking']
background_color: "#f5f5f0"
theme_color: "#fabf04"
categories: ['business', 'productivity']
icons:
- src: '%kernel.project_dir%/public/assets/notif.png'
sizes: [192]
- src: '%kernel.project_dir%/public/favicon.png'
sizes: [192, 512]

View File

@@ -6,6 +6,7 @@ nelmio_security:
- 'nonce'
- 'https://static.cloudflareinsights.com'
- 'https://challenges.cloudflare.com'
- 'https://js.stripe.com'
# Restreindre les soumissions de formulaires à notre domaine
# et aux redirections OAuth des plateformes de partage social
@@ -16,7 +17,13 @@ nelmio_security:
- 'https://twitter.com'
# Autoriser navigator.share() (Web Share API) et clipboard API
# — les deux sont des APIs navigateur natives, pas des appels réseau externes
# Ce bloc est présent pour documentation et futures intégrations
# Stripe Elements necessite connect-src vers api.stripe.com
connect-src:
- 'self'
- 'https://api.stripe.com'
# Stripe Elements charge ses iframes depuis js.stripe.com
frame-src:
- 'self'
- 'https://js.stripe.com'
- 'https://hooks.stripe.com'

View File

@@ -5,7 +5,7 @@ scheb_two_factor:
email:
enabled: true
sender_email: 'contact@siteconseil.fr'
sender_email: 'contact@e-cosplay.fr'
sender_name: 'CRM SITECONSEIL'
digits: 6
template: 'security/2fa_email.html.twig'

View File

@@ -1,5 +1,7 @@
twig:
file_name_pattern: '*.twig'
globals:
tva_enabled: '%env(bool:TVA_ENABLED)%'
when@test:
twig:

View File

@@ -14,3 +14,11 @@ vich_uploader:
uri_prefix: /uploads/adverts
upload_destination: '%kernel.project_dir%/public/uploads/adverts'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
facture_pdf:
uri_prefix: /uploads/factures
upload_destination: '%kernel.project_dir%/public/uploads/factures'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
facture_prestataire_pdf:
uri_prefix: /uploads/factures_prestataires
upload_destination: '%kernel.project_dir%/public/uploads/factures_prestataires'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer

View File

@@ -189,12 +189,12 @@ services:
image: boky/postfix:latest
container_name: crm_siteconseil_postfix
restart: unless-stopped
hostname: mail.siteconseil.local
hostname: mail.e-cosplay.local
environment:
ALLOWED_SENDER_DOMAINS: siteconseil.fr siteconseil.local
ALLOWED_SENDER_DOMAINS: e-cosplay.fr e-cosplay.local
RELAYHOST: "[mailpit]:1025"
POSTFIX_myhostname: mail.siteconseil.local
POSTFIX_mydestination: siteconseil.local, localhost
POSTFIX_myhostname: mail.e-cosplay.local
POSTFIX_mydestination: e-cosplay.local, localhost
POSTFIX_mynetworks: "10.0.0.0/8 172.16.0.0/12 192.168.0.0/16"
POSTFIX_smtpd_milters: "inet:rspamd:11332"
POSTFIX_non_smtpd_milters: "inet:rspamd:11332"

View File

@@ -11,3 +11,5 @@
0 4 * * 0 echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] START app:meilisearch:setup" >> /proc/1/fd/1 && php /app/bin/console app:meilisearch:setup --env=dev >> /proc/1/fd/1 2>&1 && echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] END app:meilisearch:setup" >> /proc/1/fd/1
0 7 * * * echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] START app:cloudflare:clean" >> /proc/1/fd/1 && php /app/bin/console app:cloudflare:clean --env=dev >> /proc/1/fd/1 2>&1 && echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] END app:cloudflare:clean" >> /proc/1/fd/1
0 8 * * * echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] START app:ndd:check" >> /proc/1/fd/1 && php /app/bin/console app:ndd:check --env=dev >> /proc/1/fd/1 2>&1 && echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] END app:ndd:check" >> /proc/1/fd/1
0 9 * * * echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] START app:payment:reminder" >> /proc/1/fd/1 && php /app/bin/console app:payment:reminder --env=dev >> /proc/1/fd/1 2>&1 && echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] END app:payment:reminder" >> /proc/1/fd/1
0 9 5 * * echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] START app:reminder:factures-prestataire" >> /proc/1/fd/1 && php /app/bin/console app:reminder:factures-prestataire --env=dev >> /proc/1/fd/1 2>&1 && echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] END app:reminder:factures-prestataire" >> /proc/1/fd/1

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260407082003 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE payment_reminder (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, step VARCHAR(30) NOT NULL, sent_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, details TEXT DEFAULT NULL, advert_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX idx_payment_reminder_advert ON payment_reminder (advert_id)');
$this->addSql('CREATE INDEX idx_payment_reminder_step ON payment_reminder (step)');
$this->addSql('ALTER TABLE payment_reminder ADD CONSTRAINT FK_F10D28D07ECCB6 FOREIGN KEY (advert_id) REFERENCES advert (id) ON DELETE CASCADE NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE payment_reminder DROP CONSTRAINT FK_F10D28D07ECCB6');
$this->addSql('DROP TABLE payment_reminder');
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260407085302 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE action_log (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, action VARCHAR(50) NOT NULL, entity_id INT DEFAULT NULL, entity_type VARCHAR(50) DEFAULT NULL, message TEXT NOT NULL, context TEXT DEFAULT NULL, severity VARCHAR(20) NOT NULL, previous_state VARCHAR(50) DEFAULT NULL, new_state VARCHAR(50) DEFAULT NULL, success BOOLEAN NOT NULL, error_message TEXT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, customer_id INT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX idx_action_log_customer ON action_log (customer_id)');
$this->addSql('CREATE INDEX idx_action_log_action ON action_log (action)');
$this->addSql('CREATE INDEX idx_action_log_created ON action_log (created_at)');
$this->addSql('ALTER TABLE action_log ADD CONSTRAINT FK_B2C5F6859395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE advert_line ADD type VARCHAR(30) DEFAULT NULL');
$this->addSql('ALTER TABLE advert_line ADD service_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE devis_line ADD type VARCHAR(30) DEFAULT NULL');
$this->addSql('ALTER TABLE devis_line ADD service_id INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE action_log DROP CONSTRAINT FK_B2C5F6859395C3F3');
$this->addSql('DROP TABLE action_log');
$this->addSql('ALTER TABLE advert_line DROP type');
$this->addSql('ALTER TABLE advert_line DROP service_id');
$this->addSql('ALTER TABLE devis_line DROP type');
$this->addSql('ALTER TABLE devis_line DROP service_id');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260407105246 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE advert ADD stripe_payment_id VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE advert DROP stripe_payment_id');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260407120747 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE advert_payment (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, type VARCHAR(20) NOT NULL, amount NUMERIC(10, 2) NOT NULL, method VARCHAR(30) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, advert_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX idx_advert_payment_advert ON advert_payment (advert_id)');
$this->addSql('CREATE INDEX idx_advert_payment_type ON advert_payment (type)');
$this->addSql('ALTER TABLE advert_payment ADD CONSTRAINT FK_C766C45BD07ECCB6 FOREIGN KEY (advert_id) REFERENCES advert (id) ON DELETE CASCADE NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE advert_payment DROP CONSTRAINT FK_C766C45BD07ECCB6');
$this->addSql('DROP TABLE advert_payment');
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260407121419 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE facture_line (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, pos INT NOT NULL, title VARCHAR(255) NOT NULL, description TEXT DEFAULT NULL, price_ht NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL, type VARCHAR(30) DEFAULT NULL, service_id INT DEFAULT NULL, facture_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_3712983A7F2DEE08 ON facture_line (facture_id)');
$this->addSql('ALTER TABLE facture_line ADD CONSTRAINT FK_3712983A7F2DEE08 FOREIGN KEY (facture_id) REFERENCES facture (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE facture ADD state VARCHAR(20) DEFAULT \'created\' NOT NULL');
$this->addSql('ALTER TABLE facture ADD total_ht NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL');
$this->addSql('ALTER TABLE facture ADD total_tva NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL');
$this->addSql('ALTER TABLE facture ADD total_ttc NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL');
$this->addSql('ALTER TABLE facture ADD is_paid BOOLEAN NOT NULL');
$this->addSql('ALTER TABLE facture ADD paid_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE facture ADD paid_method VARCHAR(30) DEFAULT NULL');
$this->addSql('ALTER TABLE facture ADD facture_pdf VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE facture ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE facture ADD customer_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE facture ADD CONSTRAINT FK_FE8664109395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_FE8664109395C3F3 ON facture (customer_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE facture_line DROP CONSTRAINT FK_3712983A7F2DEE08');
$this->addSql('DROP TABLE facture_line');
$this->addSql('ALTER TABLE facture DROP CONSTRAINT FK_FE8664109395C3F3');
$this->addSql('DROP INDEX IDX_FE8664109395C3F3');
$this->addSql('ALTER TABLE facture DROP state');
$this->addSql('ALTER TABLE facture DROP total_ht');
$this->addSql('ALTER TABLE facture DROP total_tva');
$this->addSql('ALTER TABLE facture DROP total_ttc');
$this->addSql('ALTER TABLE facture DROP is_paid');
$this->addSql('ALTER TABLE facture DROP paid_at');
$this->addSql('ALTER TABLE facture DROP paid_method');
$this->addSql('ALTER TABLE facture DROP facture_pdf');
$this->addSql('ALTER TABLE facture DROP updated_at');
$this->addSql('ALTER TABLE facture DROP customer_id');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260407202410 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE advert_event (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, type VARCHAR(30) NOT NULL, details TEXT DEFAULT NULL, ip VARCHAR(45) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, advert_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX idx_advert_event_advert ON advert_event (advert_id)');
$this->addSql('CREATE INDEX idx_advert_event_type ON advert_event (type)');
$this->addSql('ALTER TABLE advert_event ADD CONSTRAINT FK_338A17CBD07ECCB6 FOREIGN KEY (advert_id) REFERENCES advert (id) ON DELETE CASCADE NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE advert_event DROP CONSTRAINT FK_338A17CBD07ECCB6');
$this->addSql('DROP TABLE advert_event');
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260407213024 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE facture_prestataire (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, num_facture VARCHAR(100) NOT NULL, montant_ht NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL, montant_ttc NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL, year SMALLINT NOT NULL, month SMALLINT NOT NULL, is_paid BOOLEAN NOT NULL, paid_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, facture_pdf VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, prestataire_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_6725BF76BE3DB2B7 ON facture_prestataire (prestataire_id)');
$this->addSql('CREATE INDEX idx_facture_presta_period ON facture_prestataire (prestataire_id, year, month)');
$this->addSql('CREATE TABLE prestataire (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, raison_sociale VARCHAR(255) NOT NULL, siret VARCHAR(14) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL, address VARCHAR(500) DEFAULT NULL, zip_code VARCHAR(10) DEFAULT NULL, city VARCHAR(255) DEFAULT NULL, state VARCHAR(20) DEFAULT \'active\' NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('ALTER TABLE facture_prestataire ADD CONSTRAINT FK_6725BF76BE3DB2B7 FOREIGN KEY (prestataire_id) REFERENCES prestataire (id) ON DELETE CASCADE NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE facture_prestataire DROP CONSTRAINT FK_6725BF76BE3DB2B7');
$this->addSql('DROP TABLE facture_prestataire');
$this->addSql('DROP TABLE prestataire');
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 2.7 MiB

BIN
public/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 3.1 MiB

BIN
public/rib.pdf Normal file

Binary file not shown.

View File

@@ -18,11 +18,11 @@ use Twig\Environment;
#[AsCommand(
name: 'app:dns:check',
description: 'Verifie la configuration DNS, AWS SES, Cloudflare et Mailcow pour les domaines SITECONSEIL',
description: 'Verifie la configuration DNS, AWS SES, Cloudflare et Mailcow pour les domaines E-Cosplay',
)]
class CheckDnsCommand extends Command
{
private const MONITOR_EMAIL = 'monitor@siteconseil.fr';
private const MONITOR_EMAIL = 'monitor@e-cosplay.fr';
public function __construct(
private DnsCheckService $dnsCheck,
@@ -41,7 +41,7 @@ class CheckDnsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Verification DNS - CRM SITECONSEIL');
$io->title('Verification DNS - CRM E-Cosplay');
$errors = [];
$warnings = [];
@@ -367,15 +367,15 @@ class CheckDnsCommand extends Command
$hasWarnings = [] !== $warnings;
if ($hasErrors) {
$subject = 'CRM SITECONSEIL - Alerte DNS : '.\count($errors).' erreur(s)';
$subject = 'CRM E-Cosplay - Alerte DNS : '.\count($errors).' erreur(s)';
$statusColor = '#dc2626';
$statusText = \count($errors).' ERREUR(S) DETECTEE(S)';
} elseif ($hasWarnings) {
$subject = 'CRM SITECONSEIL - DNS : '.\count($warnings).' avertissement(s)';
$subject = 'CRM E-Cosplay - DNS : '.\count($warnings).' avertissement(s)';
$statusColor = '#f59e0b';
$statusText = \count($warnings).' AVERTISSEMENT(S)';
} else {
$subject = 'CRM SITECONSEIL - DNS : configuration OK';
$subject = 'CRM E-Cosplay - DNS : configuration OK';
$statusColor = '#16a34a';
$statusText = 'TOUTES LES VERIFICATIONS OK';
}
@@ -446,10 +446,10 @@ class CheckDnsCommand extends Command
$this->httpClient->request('POST', $this->discordWebhook, [
'json' => [
'embeds' => [[
'title' => 'Esy-Infra : '.$title,
'title' => 'E-Infra : '.$title,
'description' => $description,
'color' => $color,
'footer' => ['text' => 'Esy-Infra - Monitoring DNS'],
'footer' => ['text' => 'E-Infra - Monitoring DNS'],
'timestamp' => (new \DateTimeImmutable())->format('c'),
]],
],

View File

@@ -20,7 +20,7 @@ use Twig\Environment;
class CheckNddCommand extends Command
{
private const EXPIRATION_THRESHOLD_DAYS = 30;
private const MONITOR_EMAIL = 'monitor@siteconseil.fr';
private const MONITOR_EMAIL = 'monitor@e-cosplay.fr';
public function __construct(
private EntityManagerInterface $em,

View File

@@ -0,0 +1,195 @@
<?php
namespace App\Command;
use App\Entity\Advert;
use App\Entity\PaymentReminder;
use App\Service\ActionService;
use App\Service\MailerService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Twig\Environment;
#[AsCommand(
name: 'app:payment:reminder',
description: 'Envoie les relances de paiement aux clients selon l\'echeancier (15j, 10j, 5j, 3j, 1j, mise en demeure, resiliation)',
)]
class PaymentReminderCommand extends Command
{
private const MONITOR_EMAIL = 'notification@e-cosplay.fr';
public function __construct(
private EntityManagerInterface $em,
private MailerService $mailer,
private Environment $twig,
private LoggerInterface $logger,
private ActionService $actionService,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Relances de paiement');
// Recupere tous les avis en etat "send" (envoyes, en attente de paiement)
$adverts = $this->em->getRepository(Advert::class)->findBy(['state' => Advert::STATE_SEND]);
if ([] === $adverts) {
$io->success('Aucun avis de paiement en attente.');
return Command::SUCCESS;
}
$now = new \DateTimeImmutable();
$sent = 0;
foreach ($adverts as $advert) {
$customer = $advert->getCustomer();
if (null === $customer || null === $customer->getEmail()) {
continue;
}
$daysSinceSend = (int) $now->diff($advert->getUpdatedAt() ?? $advert->getCreatedAt())->days;
$nextStep = $this->getNextStep($advert, $daysSinceSend);
if (null === $nextStep) {
continue;
}
$stepConfig = PaymentReminder::STEPS_CONFIG[$nextStep];
$numOrder = $advert->getOrderNumber()->getNumOrder();
$io->text(sprintf(' %s -> %s (%d j) -> %s', $numOrder, $customer->getEmail(), $daysSinceSend, $stepConfig['label']));
try {
// Execute l'action associee a l'etape
$this->executeStep($advert, $nextStep, $customer);
// Enregistre la relance
$reminder = new PaymentReminder($advert, $nextStep, 'Jour '.$daysSinceSend.' - '.$stepConfig['label']);
$this->em->persist($reminder);
$this->em->flush();
++$sent;
} catch (\Throwable $e) {
$this->logger->error('PaymentReminder: erreur '.$numOrder.' step '.$nextStep.': '.$e->getMessage());
$io->warning('Erreur '.$numOrder.' : '.$e->getMessage());
}
}
$io->success($sent.' relance(s) envoyee(s) sur '.\count($adverts).' avis en attente.');
return Command::SUCCESS;
}
/**
* Determine la prochaine etape de relance non encore envoyee.
*/
private function getNextStep(Advert $advert, int $daysSinceSend): ?string
{
$existingSteps = $this->em->getRepository(PaymentReminder::class)
->createQueryBuilder('r')
->select('r.step')
->where('r.advert = :advert')
->setParameter('advert', $advert)
->getQuery()
->getSingleColumnResult();
foreach (PaymentReminder::STEPS_CONFIG as $step => $config) {
if ($daysSinceSend >= $config['days'] && !\in_array($step, $existingSteps, true)) {
return $step;
}
}
return null;
}
private function executeStep(Advert $advert, string $step, mixed $customer): void
{
$numOrder = $advert->getOrderNumber()->getNumOrder();
match ($step) {
PaymentReminder::STEP_REMINDER_15 => $this->sendReminder($advert, $customer, 'Rappel de paiement', 'reminder'),
PaymentReminder::STEP_WARNING_10 => $this->sendReminder($advert, $customer, 'Rappel + avertissement', 'warning'),
PaymentReminder::STEP_SUSPENSION_WARNING_5 => $this->sendReminder($advert, $customer, 'Avertissement - Suspension des services imminente', 'suspension_warning'),
PaymentReminder::STEP_FINAL_REMINDER_3 => $this->sendReminder($advert, $customer, 'Ultime rappel avant suspension', 'final_reminder'),
PaymentReminder::STEP_SUSPENSION_1 => $this->handleSuspension($advert, $customer),
PaymentReminder::STEP_FORMAL_NOTICE => $this->handleFormalNotice($advert, $customer),
PaymentReminder::STEP_TERMINATION_WARNING => $this->handleTerminationWarning($advert, $customer),
PaymentReminder::STEP_TERMINATION => $this->handleTermination($advert, $customer),
};
// Notification admin pour chaque etape
$this->mailer->sendEmail(
self::MONITOR_EMAIL,
'Relance '.$numOrder.' - '.PaymentReminder::STEPS_CONFIG[$step]['label'].' - '.$customer->getFullName(),
$this->twig->render('emails/payment_reminder_admin.html.twig', [
'customer' => $customer,
'advert' => $advert,
'step' => $step,
'stepLabel' => PaymentReminder::STEPS_CONFIG[$step]['label'],
'severity' => PaymentReminder::STEPS_CONFIG[$step]['severity'],
]),
null,
null,
false,
);
}
private function sendReminder(Advert $advert, mixed $customer, string $subject, string $type): void
{
$this->mailer->sendEmail(
$customer->getEmail(),
$subject.' - Avis '.$advert->getOrderNumber()->getNumOrder(),
$this->twig->render('emails/payment_reminder_client.html.twig', [
'customer' => $customer,
'advert' => $advert,
'type' => $type,
'subject' => $subject,
]),
null,
null,
false,
);
}
private function handleSuspension(Advert $advert, mixed $customer): void
{
$this->sendReminder($advert, $customer, 'Suspension de vos services', 'suspension');
// Suspension effective via ActionService (suspend client + sites + emails)
$this->actionService->suspendCustomer($customer, 'Impaye avis '.$advert->getOrderNumber()->getNumOrder());
}
private function handleFormalNotice(Advert $advert, mixed $customer): void
{
$this->sendReminder($advert, $customer, 'Mise en demeure de paiement', 'formal_notice');
}
private function handleTerminationWarning(Advert $advert, mixed $customer): void
{
$this->sendReminder($advert, $customer, 'Avertissement - Resiliation du contrat et suppression de vos donnees', 'termination_warning');
// Desactive le client definitivement
$this->actionService->disableCustomer($customer, 'Pre-resiliation impaye avis '.$advert->getOrderNumber()->getNumOrder());
}
private function handleTermination(Advert $advert, mixed $customer): void
{
$this->sendReminder($advert, $customer, 'Resiliation de contrat et mise en recouvrement', 'termination');
// Marque le client pour suppression (cron nocturne supprimera les donnees)
$this->actionService->markForDeletion($customer, 'Resiliation + recouvrement avis '.$advert->getOrderNumber()->getNumOrder());
// Annule l'avis
$advert->setState(Advert::STATE_CANCEL);
$this->em->flush();
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Command;
use App\Entity\Prestataire;
use App\Service\MailerService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:reminder:factures-prestataire',
description: 'Rappel mensuel pour saisir les factures prestataires du mois precedent',
)]
class ReminderFacturesPrestataireCommand extends Command
{
public function __construct(
private EntityManagerInterface $em,
private MailerService $mailer,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$now = new \DateTimeImmutable();
// Mois precedent
$prev = $now->modify('first day of last month');
$year = (int) $prev->format('Y');
$month = (int) $prev->format('n');
$monthNames = [1 => 'Janvier', 'Fevrier', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Aout', 'Septembre', 'Octobre', 'Novembre', 'Decembre'];
$monthLabel = $monthNames[$month].' '.$year;
$prestataires = $this->em->getRepository(Prestataire::class)->findBy(['state' => Prestataire::STATE_ACTIVE]);
$missing = [];
foreach ($prestataires as $presta) {
$hasFacture = false;
foreach ($presta->getFactures() as $f) {
if ($f->getYear() === $year && $f->getMonth() === $month) {
$hasFacture = true;
break;
}
}
if (!$hasFacture) {
$missing[] = $presta;
}
}
if (empty($missing)) {
$io->success('Toutes les factures prestataires de '.$monthLabel.' sont saisies.');
return Command::SUCCESS;
}
$lines = [];
foreach ($missing as $p) {
$lines[] = '- '.$p->getRaisonSociale().' ('.$p->getSiret().')';
}
$html = '<p>Bonjour,</p>'
.'<p>Les factures prestataires suivantes pour <strong>'.$monthLabel.'</strong> n\'ont pas encore ete saisies :</p>'
.'<ul style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; color: #374151; line-height: 22px;">';
foreach ($missing as $p) {
$html .= '<li><strong>'.$p->getRaisonSociale().'</strong> '.($p->getSiret() ? '('.$p->getSiret().')' : '').'</li>';
}
$html .= '</ul>'
.'<p>Merci de les ajouter dans le CRM : <a href="https://crm.e-cosplay.fr/admin/prestataires" style="color: #fabf04; font-weight: bold;">Espace Prestataires</a></p>';
$this->mailer->sendEmail(
$this->mailer->getAdminEmail(),
'Rappel : factures prestataires '.$monthLabel.' manquantes',
$html,
null,
null,
false,
);
$io->warning(\count($missing).' facture(s) prestataire(s) manquante(s) pour '.$monthLabel.'. Email de rappel envoye.');
return Command::SUCCESS;
}
}

View File

@@ -44,8 +44,8 @@ class TestMailCommand extends Command
$forceDsn = $input->getOption('force-dsn');
$subject = 'prod' === $env
? '[PROD] CRM SITECONSEIL - Email de test production'
: '[DEV] CRM SITECONSEIL - Email de test developpement';
? '[PROD] CRM E-Cosplay - Email de test production'
: '[DEV] CRM E-Cosplay - Email de test developpement';
$io->title('Envoi du mail de test ('.$env.')');
$io->text('Destinataire : '.$email);
@@ -83,7 +83,7 @@ class TestMailCommand extends Command
$directMailer = new Mailer($transport);
$email = (new Email())
->from('SARL SITECONSEIL <contact@siteconseil.fr>')
->from('Association E-Cosplay <contact@e-cosplay.fr>')
->to($to)
->subject($subject)
->html($html);

View File

@@ -3,6 +3,8 @@
namespace App\Controller\Admin;
use App\Entity\Advert;
use App\Entity\AdvertEvent;
use App\Service\FactureService;
use App\Service\MailerService;
use App\Service\MeilisearchService;
use App\Service\Pdf\AdvertPdf;
@@ -27,15 +29,34 @@ class AdvertController extends AbstractController
) {
}
#[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])]
public function generatePdf(int $id, KernelInterface $kernel, UrlGeneratorInterface $urlGenerator): Response
#[Route('/{id}/events', name: 'events', requirements: ['id' => '\d+'], methods: ['GET'])]
public function events(int $id): Response
{
$advert = $this->em->getRepository(Advert::class)->find($id);
if (null === $advert) {
throw $this->createNotFoundException('Avis de paiement introuvable');
}
$pdf = new AdvertPdf($kernel, $advert, $urlGenerator);
$events = $this->em->getRepository(AdvertEvent::class)->findBy(
['advert' => $advert],
['createdAt' => 'DESC']
);
return $this->render('admin/advert/events.html.twig', [
'advert' => $advert,
'events' => $events,
]);
}
#[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])]
public function generatePdf(int $id, KernelInterface $kernel, UrlGeneratorInterface $urlGenerator, \Twig\Environment $twig): Response
{
$advert = $this->em->getRepository(Advert::class)->find($id);
if (null === $advert) {
throw $this->createNotFoundException('Avis de paiement introuvable');
}
$pdf = new AdvertPdf($kernel, $advert, $urlGenerator, $twig);
$pdf->generate();
$tmpPath = tempnam(sys_get_temp_dir(), 'advert_').'.pdf';
@@ -140,6 +161,9 @@ class AdvertController extends AbstractController
$this->meilisearch->indexAdvert($advert);
$this->em->persist(new AdvertEvent($advert, AdvertEvent::TYPE_MAIL_SEND, 'Avis envoye a '.$customer->getEmail()));
$this->em->flush();
$this->addFlash('success', 'Avis de paiement envoye a '.$customer->getEmail().'.');
return $this->redirectToRoute('app_admin_clients_show', [
@@ -207,6 +231,9 @@ class AdvertController extends AbstractController
$attachments,
);
$this->em->persist(new AdvertEvent($advert, AdvertEvent::TYPE_MAIL_SEND, 'Rappel envoye a '.$customer->getEmail()));
$this->em->flush();
$this->addFlash('success', 'Avis de paiement renvoye a '.$customer->getEmail().'.');
return $this->redirectToRoute('app_admin_clients_show', [
@@ -228,6 +255,129 @@ class AdvertController extends AbstractController
return $this->json($results);
}
#[Route('/{id}/create-facture', name: 'create_facture', requirements: ['id' => '\d+'], methods: ['POST'])]
public function createFacture(int $id, FactureService $factureService): Response
{
$advert = $this->em->getRepository(Advert::class)->find($id);
if (null === $advert) {
throw $this->createNotFoundException('Avis de paiement introuvable');
}
if (Advert::STATE_ACCEPTED !== $advert->getState()) {
$this->addFlash('error', 'L\'avis doit etre paye pour creer une facture.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $advert->getCustomer()?->getId() ?? 0,
'tab' => 'avis',
]);
}
if ($advert->getFactures()->count() > 0) {
$this->addFlash('error', 'Une facture existe deja pour cet avis.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $advert->getCustomer()?->getId() ?? 0,
'tab' => 'avis',
]);
}
$facture = $factureService->createFromAdvert($advert);
$this->addFlash('success', 'Facture '.$facture->getInvoiceNumber().' creee.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $advert->getCustomer()?->getId() ?? 0,
'tab' => 'avis',
]);
}
#[Route('/{id}/sync-payment', name: 'sync_payment', requirements: ['id' => '\d+'], methods: ['POST'])]
public function syncPayment(
int $id,
FactureService $factureService,
#[\Symfony\Component\DependencyInjection\Attribute\Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
): Response {
$advert = $this->em->getRepository(Advert::class)->find($id);
if (null === $advert) {
throw $this->createNotFoundException('Avis de paiement introuvable');
}
$piId = $advert->getStripePaymentId();
if (null === $piId || '' === $stripeSk) {
$this->addFlash('error', 'Pas de PaymentIntent ou Stripe non configure.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $advert->getCustomer()?->getId() ?? 0,
'tab' => 'avis',
]);
}
try {
\Stripe\Stripe::setApiKey($stripeSk);
$pi = \Stripe\PaymentIntent::retrieve($piId);
if ('succeeded' === $pi->status) {
$amount = number_format($pi->amount_received / 100, 2, '.', '');
$metadata = $pi->metadata instanceof \Stripe\StripeObject ? $pi->metadata->toArray() : (array) ($pi->metadata ?? []);
$method = $metadata['payment_method'] ?? ($pi->payment_method_types[0] ?? 'card');
$methodLabel = match ($method) {
'sepa_debit' => 'Prelevement SEPA',
'customer_balance' => 'Virement bancaire (Stripe)',
'paypal' => 'PayPal',
'klarna' => 'Klarna',
'revolut_pay' => 'Revolut Pay',
'amazon_pay' => 'Amazon Pay',
'link' => 'Link (Stripe)',
default => 'Carte bancaire',
};
// Maj etat advert
if (Advert::STATE_ACCEPTED !== $advert->getState()) {
$advert->setState(Advert::STATE_ACCEPTED);
}
// Creer AdvertPayment si pas deja present
$existingPayment = $this->em->getRepository(\App\Entity\AdvertPayment::class)
->findOneBy(['advert' => $advert, 'type' => \App\Entity\AdvertPayment::TYPE_SUCCESS]);
if (null === $existingPayment) {
$payment = new \App\Entity\AdvertPayment($advert, \App\Entity\AdvertPayment::TYPE_SUCCESS, $amount);
$payment->setMethod($method);
$this->em->persist($payment);
}
// Generer la facture si pas deja presente
if ($advert->getFactures()->count() === 0) {
$factureService->createPaidFactureFromAdvert($advert, $amount, $methodLabel);
} else {
// Mettre a jour la facture existante
$facture = $advert->getFactures()->first();
if (false !== $facture && !$facture->isPaid()) {
$facture->setIsPaid(true);
$facture->setPaidAt(new \DateTimeImmutable());
$facture->setPaidMethod($methodLabel);
$facture->setState(\App\Entity\Facture::STATE_PAID);
}
}
$this->em->flush();
$this->meilisearch->indexAdvert($advert);
$this->addFlash('success', 'Sync Stripe OK : avis '.$advert->getOrderNumber()->getNumOrder().' paye ('.$methodLabel.', '.$amount.' EUR).');
} else {
$this->addFlash('warning', 'PaymentIntent statut : '.$pi->status.' (pas encore succeeded).');
}
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur sync Stripe : '.$e->getMessage());
}
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $advert->getCustomer()?->getId() ?? 0,
'tab' => 'avis',
]);
}
#[Route('/{id}/cancel', name: 'cancel', requirements: ['id' => '\d+'], methods: ['POST'])]
public function cancel(int $id): Response
{

View File

@@ -83,7 +83,11 @@ class ClientsController extends AbstractController
$this->finalizeStripeCustomer($customer, $user, $stripeSecretKey);
$codeComptable = trim($request->request->getString('codeComptable'));
$customer->setCodeComptable('' !== $codeComptable ? '411_'.$codeComptable : $customerRepository->generateUniqueCodeComptable($customer));
if ('' !== $codeComptable) {
$customer->setCodeComptable(str_starts_with($codeComptable, 'EC-') ? $codeComptable : 'EC-'.$codeComptable);
} else {
$customer->setCodeComptable($customerRepository->generateUniqueCodeComptable($customer));
}
$em->flush();
$this->indexInMeilisearch($meilisearch, $customer, $logger);
@@ -201,7 +205,7 @@ class ClientsController extends AbstractController
$mailer->sendEmail(
$user->getEmail(),
'CRM SITECONSEIL - Bienvenue dans votre espace client',
'CRM E-Cosplay - Bienvenue dans votre espace client',
$twig->render('emails/client_created.html.twig', [
'firstName' => $user->getFirstName(),
'email' => $user->getEmail(),
@@ -388,6 +392,7 @@ class ClientsController extends AbstractController
$websites = $em->getRepository(\App\Entity\Website::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$devisList = $em->getRepository(\App\Entity\Devis::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$advertsList = $em->getRepository(\App\Entity\Advert::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$facturesList = $em->getRepository(\App\Entity\Facture::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
return $this->render('admin/clients/show.html.twig', [
'customer' => $customer,
@@ -397,6 +402,7 @@ class ClientsController extends AbstractController
'websites' => $websites,
'devisList' => $devisList,
'advertsList' => $advertsList,
'facturesList' => $facturesList,
'tab' => $tab,
]);
}

File diff suppressed because it is too large Load Diff

View File

@@ -55,6 +55,18 @@ class DashboardController extends AbstractController
$results[] = ['type' => 'revendeur', 'label' => $h['fullName'] ?? $h['raisonSociale'] ?? '', 'sub' => $h['codeRevendeur'] ?? '', 'url' => '/admin/revendeurs/'.($h['id'] ?? 0).'/edit'];
}
foreach ($meilisearch->searchDevis($q, 3) as $h) {
$results[] = ['type' => 'devis', 'label' => $h['numOrder'] ?? '', 'sub' => ($h['customerName'] ?? '').' — '.($h['totalTtc'] ?? '0').' EUR', 'url' => '/admin/clients/'.($h['customerId'] ?? 0).'?tab=devis'];
}
foreach ($meilisearch->searchAdverts($q, 3) as $h) {
$results[] = ['type' => 'avis', 'label' => $h['numOrder'] ?? '', 'sub' => ($h['customerName'] ?? '').' — '.($h['totalTtc'] ?? '0').' EUR', 'url' => '/admin/clients/'.($h['customerId'] ?? 0).'?tab=avis'];
}
foreach ($meilisearch->searchFactures($q, 3) as $h) {
$results[] = ['type' => 'facture', 'label' => $h['invoiceNumber'] ?? '', 'sub' => ($h['customerName'] ?? '').' — '.($h['totalTtc'] ?? '0').' EUR', 'url' => '/admin/clients/'.($h['customerId'] ?? 0).'?tab=factures'];
}
return new JsonResponse($results);
}
}

View File

@@ -31,8 +31,6 @@ use Twig\Environment;
#[IsGranted('ROLE_EMPLOYE')]
class DevisController extends AbstractController
{
private const TVA_RATE = 0.20;
public function __construct(
private EntityManagerInterface $em,
private OrderNumberService $orderNumberService,
@@ -41,6 +39,42 @@ class DevisController extends AbstractController
) {
}
#[Route('/services/{customerId}/{type}', name: 'services', requirements: ['customerId' => '\d+'], methods: ['GET'])]
public function services(int $customerId, string $type): Response
{
$customer = $this->em->getRepository(Customer::class)->find($customerId);
if (null === $customer) {
return $this->json([]);
}
$items = match ($type) {
'ndd' => array_map(
fn ($d) => ['id' => $d->getId(), 'label' => $d->getFqdn()],
$this->em->getRepository(\App\Entity\Domain::class)->findBy(['customer' => $customer])
),
'website' => array_map(
fn ($w) => ['id' => $w->getId(), 'label' => $w->getName().' ('.$w->getType().')'],
$this->em->getRepository(\App\Entity\Website::class)->findBy(['customer' => $customer])
),
'esymail' => array_map(
function ($domain) {
$emails = $this->em->getRepository(\App\Entity\DomainEmail::class)->findBy(['domain' => $domain]);
return array_map(fn ($e) => ['id' => $e->getId(), 'label' => $e->getFullEmail()], $emails);
},
$this->em->getRepository(\App\Entity\Domain::class)->findBy(['customer' => $customer])
),
default => [],
};
// Flatten esymail (array of arrays)
if ('esymail' === $type) {
$items = array_merge(...$items);
}
return $this->json($items);
}
#[Route('/search/{customerId}', name: 'search', requirements: ['customerId' => '\d+'], methods: ['GET'])]
public function search(int $customerId, Request $request): Response
{
@@ -157,6 +191,14 @@ class DevisController extends AbstractController
if ('' !== $description) {
$line->setDescription($description);
}
$lineType = trim((string) ($data['type'] ?? ''));
if ('' !== $lineType) {
$line->setType($lineType);
}
$lineServiceId = (int) ($data['serviceId'] ?? 0);
if ($lineServiceId > 0) {
$line->setServiceId($lineServiceId);
}
$this->em->persist($line);
$devis->addLine($line);
@@ -173,12 +215,11 @@ class DevisController extends AbstractController
: $this->redirectToRoute('app_admin_devis_create', ['customerId' => $customer->getId()]);
}
$totalTva = round($totalHt * self::TVA_RATE, 2);
$totalTtc = round($totalHt + $totalTva, 2);
$totals = $this->devisService->computeTotals(number_format($totalHt, 2, '.', ''));
$devis->setTotalHt(number_format($totalHt, 2, '.', ''));
$devis->setTotalTva(number_format($totalTva, 2, '.', ''));
$devis->setTotalTtc(number_format($totalTtc, 2, '.', ''));
$devis->setTotalHt($totals['totalHt']);
$devis->setTotalTva($totals['totalTva']);
$devis->setTotalTtc($totals['totalTtc']);
$this->em->flush();
@@ -193,7 +234,7 @@ class DevisController extends AbstractController
}
#[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])]
public function generatePdf(int $id, KernelInterface $kernel): Response
public function generatePdf(int $id, KernelInterface $kernel, \Twig\Environment $twig): Response
{
$devis = $this->em->getRepository(Devis::class)->find($id);
if (null === $devis) {
@@ -201,7 +242,7 @@ class DevisController extends AbstractController
}
// Generation du PDF (devis + CGV fusionnees via FPDI)
$pdf = new DevisPdf($kernel, $devis);
$pdf = new DevisPdf($kernel, $devis, $twig);
$pdf->generate();
// Ecriture dans un fichier temporaire
@@ -478,6 +519,12 @@ class DevisController extends AbstractController
if (null !== $devisLine->getDescription()) {
$advertLine->setDescription($devisLine->getDescription());
}
if (null !== $devisLine->getType()) {
$advertLine->setType($devisLine->getType());
}
if (null !== $devisLine->getServiceId()) {
$advertLine->setServiceId($devisLine->getServiceId());
}
$this->em->persist($advertLine);
$advert->addLine($advertLine);
}

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Controller\Admin;
use App\Entity\Facture;
use App\Service\MailerService;
use App\Service\MeilisearchService;
use App\Service\Pdf\FacturePdf;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Twig\Environment;
#[Route('/admin/facture', name: 'app_admin_facture_')]
#[IsGranted('ROLE_EMPLOYE')]
class FactureController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em,
private MeilisearchService $meilisearch,
) {
}
#[Route('/search/{customerId}', name: 'search', requirements: ['customerId' => '\d+'], methods: ['GET'])]
public function search(int $customerId, Request $request): JsonResponse
{
$query = trim($request->query->getString('q'));
if ('' === $query) {
return $this->json([]);
}
return $this->json($this->meilisearch->searchFactures($query, 20, $customerId));
}
#[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])]
public function generatePdf(int $id, KernelInterface $kernel, UrlGeneratorInterface $urlGenerator, Environment $twig): Response
{
$facture = $this->em->getRepository(Facture::class)->find($id);
if (null === $facture) {
throw $this->createNotFoundException('Facture introuvable');
}
$pdf = new FacturePdf($kernel, $facture, $urlGenerator, $twig);
$pdf->generate();
$tmpPath = tempnam(sys_get_temp_dir(), 'facture_').'.pdf';
$pdf->Output('F', $tmpPath);
$hadOld = null !== $facture->getFacturePdf();
$uploadDir = $kernel->getProjectDir().'/public/uploads/factures';
if ($hadOld) {
$oldPath = $uploadDir.'/'.$facture->getFacturePdf();
if (file_exists($oldPath)) {
@unlink($oldPath);
}
$facture->setFacturePdf(null);
}
$uploadedFile = new UploadedFile(
$tmpPath,
'facture-'.str_replace('/', '-', $facture->getInvoiceNumber()).'.pdf',
'application/pdf',
null,
true
);
$facture->setFacturePdfFile($uploadedFile);
$facture->setUpdatedAt(new \DateTimeImmutable());
$this->em->flush();
@unlink($tmpPath);
$this->addFlash('success', 'PDF facture '.$facture->getInvoiceNumber().' '.($hadOld ? 'regenere' : 'genere').'.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $facture->getCustomer()?->getId() ?? 0,
'tab' => 'factures',
]);
}
#[Route('/{id}/send', name: 'send', requirements: ['id' => '\d+'], methods: ['POST'])]
public function send(
int $id,
MailerService $mailer,
Environment $twig,
UrlGeneratorInterface $urlGenerator,
#[Autowire('%kernel.project_dir%')] string $projectDir,
): Response {
$facture = $this->em->getRepository(Facture::class)->find($id);
if (null === $facture) {
throw $this->createNotFoundException('Facture introuvable');
}
if (null === $facture->getFacturePdf()) {
$this->addFlash('error', 'Le PDF doit etre genere avant l\'envoi.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $facture->getCustomer()?->getId() ?? 0,
'tab' => 'factures',
]);
}
$customer = $facture->getCustomer();
if (null === $customer || null === $customer->getEmail()) {
$this->addFlash('error', 'Client ou email introuvable.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $customer?->getId() ?? 0,
'tab' => 'factures',
]);
}
$invoiceNumber = $facture->getInvoiceNumber();
$attachments = [];
$pdfPath = $projectDir.'/public/uploads/factures/'.$facture->getFacturePdf();
if (file_exists($pdfPath)) {
$attachments[] = ['path' => $pdfPath, 'name' => 'facture-'.str_replace('/', '-', $invoiceNumber).'.pdf'];
}
$verifyUrl = $urlGenerator->generate('app_facture_verify', [
'id' => $facture->getId(),
'hmac' => $facture->getHmac(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$html = $twig->render('emails/facture_send.html.twig', [
'customer' => $customer,
'facture' => $facture,
'verifyUrl' => $verifyUrl,
]);
$mailer->sendEmail(
$customer->getEmail(),
'Facture '.$invoiceNumber,
$html,
null,
null,
false,
$attachments,
);
$facture->setState(Facture::STATE_SEND);
$this->em->flush();
$this->addFlash('success', 'Facture '.$invoiceNumber.' envoyee a '.$customer->getEmail().'.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $customer->getId(),
'tab' => 'factures',
]);
}
}

View File

@@ -121,7 +121,7 @@ class LogsController extends AbstractController
$hmacValid = $loggerService->verifyLog($log);
$logoPath = $projectDir.'/public/logo_facture.png';
$logoPath = $projectDir.'/public/logo.jpg';
$logo = file_exists($logoPath) ? 'data:image/png;base64,'.base64_encode((string) file_get_contents($logoPath)) : '';
$verifyUrl = $this->generateUrl('app_log_verify', [

View File

@@ -159,7 +159,7 @@ class MembresController extends AbstractController
$user->setFirstName($data['firstName']);
$user->setLastName($data['lastName']);
$user->setKeycloakId($data['keycloakId']);
$user->setRoles(\in_array('siteconseil_admin', $data['groups'], true) ? ['ROLE_ROOT'] : ['ROLE_EMPLOYE']);
$user->setRoles(\in_array('superadmin', $data['groups'], true) ? ['ROLE_ROOT'] : ['ROLE_EMPLOYE']);
$user->setPassword($passwordHasher->hashPassword($user, $data['tempPassword']));
$user->setTempPassword($data['tempPassword']);
@@ -183,7 +183,7 @@ class MembresController extends AbstractController
): void {
$mailer->sendEmail(
$email,
'CRM SITECONSEIL - Votre compte a ete cree',
'CRM E-Cosplay - Votre compte a ete cree',
$twig->render('emails/membre_created.html.twig', [
'firstName' => $firstName,
'lastName' => $lastName,
@@ -214,7 +214,7 @@ class MembresController extends AbstractController
$mailer->sendEmail(
$user->getEmail(),
'CRM SITECONSEIL - Rappel : votre compte a ete cree',
'CRM E-Cosplay - Rappel : votre compte a ete cree',
$twig->render('emails/membre_created.html.twig', [
'firstName' => $user->getFirstName(),
'lastName' => $user->getLastName(),

View File

@@ -0,0 +1,197 @@
<?php
namespace App\Controller\Admin;
use App\Entity\FacturePrestataire;
use App\Entity\Prestataire;
use App\Repository\PrestataireRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[Route('/admin/prestataires', name: 'app_admin_prestataires_')]
#[IsGranted('ROLE_ROOT')]
class PrestatairesController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em,
) {
}
#[Route('', name: 'index')]
public function index(PrestataireRepository $repo): Response
{
$prestataires = $repo->findBy([], ['raisonSociale' => 'ASC']);
return $this->render('admin/prestataires/index.html.twig', [
'prestataires' => $prestataires,
]);
}
#[Route('/create', name: 'create', methods: ['POST'])]
public function create(Request $request): Response
{
$raisonSociale = trim($request->request->getString('raisonSociale'));
if ('' === $raisonSociale) {
$this->addFlash('error', 'La raison sociale est obligatoire.');
return $this->redirectToRoute('app_admin_prestataires_index');
}
$prestataire = new Prestataire($raisonSociale);
$prestataire->setSiret($request->request->getString('siret') ?: null);
$prestataire->setEmail($request->request->getString('email') ?: null);
$prestataire->setPhone($request->request->getString('phone') ?: null);
$prestataire->setAddress($request->request->getString('address') ?: null);
$prestataire->setZipCode($request->request->getString('zipCode') ?: null);
$prestataire->setCity($request->request->getString('city') ?: null);
$this->em->persist($prestataire);
$this->em->flush();
$this->addFlash('success', 'Prestataire "'.$raisonSociale.'" cree.');
return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]);
}
#[Route('/{id}', name: 'show', requirements: ['id' => '\d+'])]
public function show(Prestataire $prestataire): Response
{
return $this->render('admin/prestataires/show.html.twig', [
'prestataire' => $prestataire,
]);
}
#[Route('/{id}/edit', name: 'edit', requirements: ['id' => '\d+'], methods: ['POST'])]
public function edit(Prestataire $prestataire, Request $request): Response
{
$raisonSociale = trim($request->request->getString('raisonSociale'));
if ('' !== $raisonSociale) {
$prestataire->setRaisonSociale($raisonSociale);
}
$prestataire->setSiret($request->request->getString('siret') ?: null);
$prestataire->setEmail($request->request->getString('email') ?: null);
$prestataire->setPhone($request->request->getString('phone') ?: null);
$prestataire->setAddress($request->request->getString('address') ?: null);
$prestataire->setZipCode($request->request->getString('zipCode') ?: null);
$prestataire->setCity($request->request->getString('city') ?: null);
$this->em->flush();
$this->addFlash('success', 'Prestataire mis a jour.');
return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]);
}
#[Route('/{id}/delete', name: 'delete', requirements: ['id' => '\d+'], methods: ['POST'])]
public function delete(Prestataire $prestataire): Response
{
$name = $prestataire->getRaisonSociale();
$this->em->remove($prestataire);
$this->em->flush();
$this->addFlash('success', 'Prestataire "'.$name.'" supprime.');
return $this->redirectToRoute('app_admin_prestataires_index');
}
/**
* Proxy recherche entreprise via API data.gouv.fr.
*/
#[Route('/entreprise-search', name: 'entreprise_search', methods: ['GET'])]
public function entrepriseSearch(Request $request, HttpClientInterface $httpClient): JsonResponse
{
$query = trim($request->query->getString('q'));
if (\strlen($query) < 2) {
return new JsonResponse(['results' => [], 'total_results' => 0]);
}
try {
$response = $httpClient->request('GET', 'https://recherche-entreprises.api.gouv.fr/search', [
'query' => [
'q' => $query,
'page' => 1,
'per_page' => 5,
],
]);
return new JsonResponse($response->toArray());
} catch (\Throwable) {
return new JsonResponse(['results' => [], 'total_results' => 0, 'error' => 'Service indisponible'], 502);
}
}
// ---------------------------------------------------------------
// Factures prestataire
// ---------------------------------------------------------------
#[Route('/{id}/facture/add', name: 'facture_add', requirements: ['id' => '\d+'], methods: ['POST'])]
public function addFacture(Prestataire $prestataire, Request $request): Response
{
$numFacture = trim($request->request->getString('numFacture'));
$montantHt = $request->request->getString('montantHt');
$montantTtc = $request->request->getString('montantTtc');
$year = $request->request->getInt('year');
$month = $request->request->getInt('month');
if ('' === $numFacture || $year < 2020 || $month < 1 || $month > 12) {
$this->addFlash('error', 'Donnees invalides.');
return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]);
}
$facture = new FacturePrestataire($prestataire, $numFacture, $year, $month);
$facture->setMontantHt(number_format((float) $montantHt, 2, '.', ''));
$facture->setMontantTtc(number_format((float) $montantTtc, 2, '.', ''));
/** @var UploadedFile|null $file */
$file = $request->files->get('facturePdf');
if (null !== $file && $file->isValid()) {
$facture->setFacturePdfFile($file);
}
$prestataire->addFacture($facture);
$this->em->persist($facture);
$this->em->flush();
$this->addFlash('success', 'Facture '.$numFacture.' ajoutee pour '.$facture->getPeriodLabel().'.');
return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]);
}
#[Route('/{id}/facture/{factureId}/paid', name: 'facture_paid', requirements: ['id' => '\d+', 'factureId' => '\d+'], methods: ['POST'])]
public function markPaid(Prestataire $prestataire, int $factureId): Response
{
$facture = $this->em->getRepository(FacturePrestataire::class)->find($factureId);
if (null !== $facture && $facture->getPrestataire()->getId() === $prestataire->getId()) {
$facture->setIsPaid(true);
$facture->setPaidAt(new \DateTimeImmutable());
$this->em->flush();
$this->addFlash('success', 'Facture '.$facture->getNumFacture().' marquee payee.');
}
return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]);
}
#[Route('/{id}/facture/{factureId}/delete', name: 'facture_delete', requirements: ['id' => '\d+', 'factureId' => '\d+'], methods: ['POST'])]
public function deleteFacture(Prestataire $prestataire, int $factureId): Response
{
$facture = $this->em->getRepository(FacturePrestataire::class)->find($factureId);
if (null !== $facture && $facture->getPrestataire()->getId() === $prestataire->getId()) {
$this->em->remove($facture);
$this->em->flush();
$this->addFlash('success', 'Facture supprimee.');
}
return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]);
}
}

View File

@@ -86,7 +86,7 @@ class RevendeursController extends AbstractController
$mailer->sendEmail(
$email,
'CRM SITECONSEIL - Bienvenue dans l\'espace revendeur',
'CRM E-Cosplay - Bienvenue dans l\'espace revendeur',
$twig->render('emails/revendeur_created.html.twig', [
'firstName' => $firstName,
'lastName' => $lastName,
@@ -180,7 +180,7 @@ class RevendeursController extends AbstractController
Environment $twig,
#[Autowire('%kernel.project_dir%')] string $projectDir,
): Response {
$logoPath = $projectDir.'/public/logo_facture.png';
$logoPath = $projectDir.'/public/logo.jpg';
$logo = file_exists($logoPath) ? 'data:image/jpeg;base64,'.base64_encode(file_get_contents($logoPath)) : '';
$html = $twig->render('pdf/contrat_revendeur.html.twig', [

View File

@@ -2,6 +2,11 @@
namespace App\Controller\Admin;
use App\Entity\Advert;
use App\Entity\AdvertPayment;
use App\Entity\Facture;
use App\Entity\FacturePrestataire;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -16,6 +21,11 @@ class StatsController extends AbstractController
private const STATUS_RENTABLE = 'Rentable';
private const STATUS_NEGATIF = 'Negatif';
public function __construct(
private EntityManagerInterface $em,
) {
}
#[Route('', name: 'index')]
public function index(Request $request): Response
{
@@ -34,87 +44,307 @@ class StatsController extends AbstractController
$dateTo = $now->format('Y-m-d');
}
// Donnees fictives
$rawServices = [
['name' => 'Esy-Web', 'slug' => 'esy-web', 'color' => '#fabf04', 'ca_ht' => 4_200.00, 'cout_infra' => 80.00, 'cout_prestataire' => 70.00, 'clients' => 12, 'abonnements' => 12],
['name' => 'Esy-Mail', 'slug' => 'esy-mail', 'color' => '#dc2626', 'ca_ht' => 850.00, 'cout_infra' => 45.00, 'cout_prestataire' => 25.00, 'clients' => 15, 'abonnements' => 42],
['name' => 'Esy-Mailer', 'slug' => 'esy-mailer', 'color' => '#ea580c', 'ca_ht' => 1_100.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 4, 'abonnements' => 4],
['name' => 'Esy-Analytics', 'slug' => 'esy-analytics', 'color' => '#4338ca', 'ca_ht' => 0.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 12, 'abonnements' => 12],
['name' => 'Esy-Monitor', 'slug' => 'esy-monitor', 'color' => '#16a34a', 'ca_ht' => 0.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 12, 'abonnements' => 12],
['name' => 'Esy-Defender', 'slug' => 'esy-defender', 'color' => '#0891b2', 'ca_ht' => 600.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 3, 'abonnements' => 3],
['name' => 'Esy-Translate', 'slug' => 'esy-translate', 'color' => '#8b5cf6', 'ca_ht' => 0.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 12, 'abonnements' => 12],
['name' => 'Esy-Signature', 'slug' => 'esy-signature', 'color' => '#7c3aed', 'ca_ht' => 400.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 2, 'abonnements' => 2],
['name' => 'Esy-Creator', 'slug' => 'esy-creator', 'color' => '#d946ef', 'ca_ht' => 1_200.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 2, 'abonnements' => 2],
['name' => 'Esy-Aide', 'slug' => 'esy-aide', 'color' => '#14b8a6', 'ca_ht' => 0.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 12, 'abonnements' => 12],
['name' => 'Esy-Meet', 'slug' => 'esy-meet', 'color' => '#f97316', 'ca_ht' => 300.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 1, 'abonnements' => 1],
['name' => 'Esy-Tchat', 'slug' => 'esy-tchat', 'color' => '#06b6d4', 'ca_ht' => 150.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 1, 'abonnements' => 1],
['name' => 'Nom de domaine','slug' => 'ndd', 'color' => '#64748b', 'ca_ht' => 350.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 8, 'abonnements' => 8],
];
$from = new \DateTimeImmutable($dateFrom);
$to = new \DateTimeImmutable($dateTo.' 23:59:59');
$servicesStats = [];
$totalCoutInfra = 0.0;
$totalCoutPrestataire = 0.0;
$totalCaHt = 0.0;
// CA dynamique depuis les factures payees
$factureStats = $this->getFactureStats($from, $to);
foreach ($rawServices as $s) {
$caTva = $s['ca_ht'] * 0.2;
$coutTotal = $s['cout_infra'] + $s['cout_prestataire'];
$margeNette = $s['ca_ht'] - $coutTotal;
// CA dynamique depuis les paiements Stripe (AdvertPayment)
$paymentStats = $this->getPaymentStats($from, $to);
$totalCoutInfra += $s['cout_infra'];
$totalCoutPrestataire += $s['cout_prestataire'];
$totalCaHt += $s['ca_ht'];
// Stats globales
$totalCaHt = (float) $factureStats['totalHt'];
$totalCaTva = (float) $factureStats['totalTva'];
$totalCaTtc = (float) $factureStats['totalTtc'];
$commissionStripe = $totalCaTtc * 0.015;
$servicesStats[] = array_merge($s, [
'ca_tva' => $caTva,
'ca_ttc' => $s['ca_ht'] + $caTva,
'cout_total' => $coutTotal,
'marge_nette' => $margeNette,
'status' => $this->resolveStatus($margeNette, $s['ca_ht']),
]);
}
$totalCoutGlobal = $totalCoutInfra + $totalCoutPrestataire;
$commissionStripe = $totalCaHt * 0.015;
$margeNetteGlobale = $totalCaHt - $totalCoutGlobal - $commissionStripe;
// Cout prestataire dynamique depuis les factures prestataires de la periode
$coutPrestataire = $this->getCoutPrestataire($from, $to);
$coutInfra = 80.0;
$coutFonctionnement = $coutInfra + $coutPrestataire;
$coutTotal = $coutFonctionnement + $commissionStripe;
$globalStats = [
'ca_ht' => $totalCaHt,
'ca_tva' => $totalCaHt * 0.2,
'ca_ttc' => $totalCaHt * 1.2,
'cout_infra' => $totalCoutInfra,
'cout_prestataire' => $totalCoutPrestataire,
'cout_total' => $totalCoutGlobal,
'ca_tva' => $totalCaTva,
'ca_ttc' => $totalCaTtc,
'commission_stripe' => $commissionStripe,
'marge_nette' => $margeNetteGlobale,
'status' => $this->resolveStatus($margeNetteGlobale, $totalCaHt),
'factures_emises' => $totalCaHt * 1.2,
'factures_payees' => $totalCaHt * 1.2 * 0.85,
'factures_impayees' => $totalCaHt * 1.2 * 0.15,
'nb_factures_emises' => 28,
'nb_factures_payees' => 24,
'nb_factures_impayees' => 4,
'cout_infra' => $coutInfra,
'cout_prestataire' => $coutPrestataire,
'cout_fonctionnement' => $coutFonctionnement,
'marge_nette' => $totalCaHt - $coutTotal,
'status' => $this->resolveStatus($totalCaHt - $coutTotal, $totalCaHt),
'factures_emises' => $factureStats['nbTotal'],
'factures_payees' => $factureStats['nbPaid'],
'factures_impayees' => $factureStats['nbTotal'] - $factureStats['nbPaid'],
'montant_emis' => $factureStats['totalEmis'],
'montant_paye' => $factureStats['totalTtc'],
'montant_impaye' => $factureStats['totalEmis'] - $factureStats['totalTtc'],
];
$monthlyEvolution = [
['month' => 'Oct 2025', 'ca_ht' => 9_800],
['month' => 'Nov 2025', 'ca_ht' => 10_200],
['month' => 'Dec 2025', 'ca_ht' => 11_500],
['month' => 'Jan 2026', 'ca_ht' => 10_900],
['month' => 'Fev 2026', 'ca_ht' => 11_800],
['month' => 'Mar 2026', 'ca_ht' => $totalCaHt],
];
// Paiements par methode
$paymentsByMethod = $this->getPaymentsByMethod($from, $to);
// Evolution mensuelle (6 derniers mois)
$monthlyEvolution = $this->getMonthlyEvolution();
// Services dynamiques depuis les lignes de factures payees
$services = $this->getServiceStats($from, $to);
return $this->render('admin/stats/index.html.twig', [
'period' => $period,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'global' => $globalStats,
'services' => $servicesStats,
'services' => $services,
'evolution' => $monthlyEvolution,
'payments' => $paymentStats,
]);
}
/**
* @return array{totalHt: string, totalTva: string, totalTtc: string, totalEmis: string, nbTotal: int, nbPaid: int}
*/
private function getFactureStats(\DateTimeImmutable $from, \DateTimeImmutable $to): array
{
$qb = $this->em->createQueryBuilder();
// Toutes les factures de la periode
$allFactures = $qb->select('f')
->from(Facture::class, 'f')
->where('f.createdAt BETWEEN :from AND :to')
->setParameter('from', $from)
->setParameter('to', $to)
->getQuery()
->getResult();
$totalHt = 0.0;
$totalTva = 0.0;
$totalTtc = 0.0;
$totalEmis = 0.0;
$nbPaid = 0;
foreach ($allFactures as $facture) {
$totalEmis += (float) $facture->getTotalTtc();
if ($facture->isPaid()) {
$totalHt += (float) $facture->getTotalHt();
$totalTva += (float) $facture->getTotalTva();
$totalTtc += (float) $facture->getTotalTtc();
++$nbPaid;
}
}
return [
'totalHt' => number_format($totalHt, 2, '.', ''),
'totalTva' => number_format($totalTva, 2, '.', ''),
'totalTtc' => number_format($totalTtc, 2, '.', ''),
'totalEmis' => $totalEmis,
'nbTotal' => \count($allFactures),
'nbPaid' => $nbPaid,
];
}
/**
* @return array{total: string, count: int}
*/
private function getPaymentStats(\DateTimeImmutable $from, \DateTimeImmutable $to): array
{
$payments = $this->em->createQueryBuilder()
->select('p')
->from(AdvertPayment::class, 'p')
->where('p.createdAt BETWEEN :from AND :to')
->andWhere('p.type = :type')
->setParameter('from', $from)
->setParameter('to', $to)
->setParameter('type', AdvertPayment::TYPE_SUCCESS)
->getQuery()
->getResult();
$total = 0.0;
foreach ($payments as $p) {
$total += (float) $p->getAmount();
}
return [
'total' => number_format($total, 2, '.', ''),
'count' => \count($payments),
];
}
/**
* @return list<array{method: string, total: float, count: int}>
*/
private function getPaymentsByMethod(\DateTimeImmutable $from, \DateTimeImmutable $to): array
{
$payments = $this->em->createQueryBuilder()
->select('p')
->from(AdvertPayment::class, 'p')
->where('p.createdAt BETWEEN :from AND :to')
->andWhere('p.type = :type')
->setParameter('from', $from)
->setParameter('to', $to)
->setParameter('type', AdvertPayment::TYPE_SUCCESS)
->getQuery()
->getResult();
$grouped = [];
foreach ($payments as $p) {
$method = $p->getMethod() ?? 'inconnu';
if (!isset($grouped[$method])) {
$grouped[$method] = ['method' => $method, 'total' => 0.0, 'count' => 0];
}
$grouped[$method]['total'] += (float) $p->getAmount();
++$grouped[$method]['count'];
}
usort($grouped, fn ($a, $b) => $b['total'] <=> $a['total']);
return array_values($grouped);
}
/**
* @return list<array{month: string, ca_ht: float, ca_ttc: float}>
*/
private function getMonthlyEvolution(): array
{
$result = [];
$now = new \DateTimeImmutable();
for ($i = 5; $i >= 0; --$i) {
$monthStart = $now->modify('-'.$i.' months')->modify('first day of this month');
$monthEnd = $monthStart->modify('last day of this month 23:59:59');
$factures = $this->em->createQueryBuilder()
->select('f')
->from(Facture::class, 'f')
->where('f.createdAt BETWEEN :from AND :to')
->andWhere('f.isPaid = true')
->setParameter('from', $monthStart)
->setParameter('to', $monthEnd)
->getQuery()
->getResult();
$caHt = 0.0;
$caTtc = 0.0;
foreach ($factures as $f) {
$caHt += (float) $f->getTotalHt();
$caTtc += (float) $f->getTotalTtc();
}
$result[] = [
'month' => $monthStart->format('M Y'),
'ca_ht' => $caHt,
'ca_ttc' => $caTtc,
];
}
return $result;
}
private const SERVICE_CONFIG = [
'website' => ['name' => 'E-Site', 'color' => '#fabf04', 'cout' => 150.00, 'group' => 'esite'],
'hosting' => ['name' => 'E-Site', 'color' => '#fabf04', 'cout' => 0.00, 'group' => 'esite'],
'maintenance' => ['name' => 'E-Site', 'color' => '#fabf04', 'cout' => 0.00, 'group' => 'esite'],
'esymail' => ['name' => 'E-Mail', 'color' => '#dc2626', 'cout' => 70.00, 'group' => 'esymail'],
'ndd' => ['name' => 'Nom de domaine', 'color' => '#64748b', 'cout' => 0.00, 'cout_par_ligne' => 15.00, 'group' => 'ndd'],
'other' => ['name' => 'Autre', 'color' => '#6b7280', 'cout' => 0.00, 'group' => 'other'],
];
/**
* @return list<array{name: string, color: string, ca_ht: float, cout: float, marge: float, status: string, clients: int}>
*/
private function getServiceStats(\DateTimeImmutable $from, \DateTimeImmutable $to): array
{
// Recuperer toutes les lignes de factures payees sur la periode
$factures = $this->em->createQueryBuilder()
->select('f')
->from(Facture::class, 'f')
->where('f.createdAt BETWEEN :from AND :to')
->andWhere('f.isPaid = true')
->setParameter('from', $from)
->setParameter('to', $to)
->getQuery()
->getResult();
// Grouper les lignes par type
$raw = [];
foreach (self::SERVICE_CONFIG as $type => $config) {
$raw[$type] = ['ca_ht' => 0.0, 'customers' => [], 'lines' => 0];
}
foreach ($factures as $facture) {
$customerId = $facture->getCustomer()?->getId();
foreach ($facture->getLines() as $line) {
$type = $line->getType() ?? 'other';
if (!isset($raw[$type])) {
$type = 'other';
}
$raw[$type]['ca_ht'] += (float) $line->getPriceHt();
// Cout par ligne NDD : uniquement Renouvellement et Depot (pas Gestion)
$title = $line->getTitle();
if ('ndd' !== $type || str_contains($title, 'Renouvellement') || str_contains($title, 'Depot')) {
++$raw[$type]['lines'];
}
if (null !== $customerId) {
$raw[$type]['customers'][$customerId] = true;
}
}
}
// Fusionner par group (website+hosting+maintenance -> esite)
$grouped = [];
foreach (self::SERVICE_CONFIG as $type => $config) {
$group = $config['group'];
if (!isset($grouped[$group])) {
$grouped[$group] = ['name' => $config['name'], 'color' => $config['color'], 'ca_ht' => 0.0, 'cout' => 0.0, 'customers' => []];
}
$grouped[$group]['ca_ht'] += $raw[$type]['ca_ht'];
$grouped[$group]['cout'] += $config['cout'] + (($config['cout_par_ligne'] ?? 0.0) * $raw[$type]['lines']);
$grouped[$group]['customers'] += $raw[$type]['customers'];
}
$services = [];
foreach ($grouped as $data) {
$marge = $data['ca_ht'] - $data['cout'];
$services[] = [
'name' => $data['name'],
'color' => $data['color'],
'ca_ht' => $data['ca_ht'],
'cout' => $data['cout'],
'marge' => $marge,
'status' => $data['ca_ht'] <= 0 ? 'Inactif' : ($marge >= 0 ? 'Rentable' : 'Negatif'),
'clients' => \count($data['customers']),
];
}
// Trier par CA decroissant
usort($services, fn ($a, $b) => $b['ca_ht'] <=> $a['ca_ht']);
return $services;
}
private function getCoutPrestataire(\DateTimeImmutable $from, \DateTimeImmutable $to): float
{
$factures = $this->em->createQueryBuilder()
->select('fp')
->from(FacturePrestataire::class, 'fp')
->where('fp.createdAt BETWEEN :from AND :to')
->setParameter('from', $from)
->setParameter('to', $to)
->getQuery()
->getResult();
$total = 0.0;
foreach ($factures as $fp) {
$total += (float) $fp->getMontantHt();
}
return $total;
}
private function resolveStatus(float $margeNette, float $caHt): string
{
if ($caHt <= 0) {

View File

@@ -6,6 +6,7 @@ use App\Entity\Advert;
use App\Entity\CustomerContact;
use App\Entity\Devis;
use App\Entity\Domain;
use App\Entity\Facture;
use App\Entity\StripeWebhookSecret;
use App\Entity\Website;
use App\Repository\CustomerRepository;
@@ -28,7 +29,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class SyncController extends AbstractController
{
#[Route('', name: 'index')]
public function index(CustomerRepository $customerRepository, RevendeurRepository $revendeurRepository, PriceAutomaticRepository $priceRepository, StripeWebhookSecretRepository $secretRepository, EntityManagerInterface $em): Response
public function index(CustomerRepository $customerRepository, RevendeurRepository $revendeurRepository, PriceAutomaticRepository $priceRepository, StripeWebhookSecretRepository $secretRepository, EntityManagerInterface $em, MeilisearchService $meilisearch): Response
{
$prices = $priceRepository->findAll();
$stripeSynced = 0;
@@ -68,10 +69,29 @@ class SyncController extends AbstractController
'totalWebsites' => $em->getRepository(Website::class)->count([]),
'totalDevis' => $em->getRepository(Devis::class)->count([]),
'totalAdverts' => $em->getRepository(Advert::class)->count([]),
'totalFactures' => $em->getRepository(Facture::class)->count([]),
'webhookSecrets' => $webhookSecrets,
'msCustomers' => $meilisearch->getIndexCount('customer'),
'msResellers' => $meilisearch->getIndexCount('reseller'),
'msPrices' => $meilisearch->getIndexCount('price_auto'),
'msContacts' => $meilisearch->getIndexCount('customer_contact'),
'msDomains' => $meilisearch->getIndexCount('customer_ndd'),
'msWebsites' => $meilisearch->getIndexCount('customer_website'),
'msDevis' => $meilisearch->getIndexCount('customer_devis'),
'msAdverts' => $meilisearch->getIndexCount('customer_advert'),
'msFactures' => $meilisearch->getIndexCount('customer_facture'),
]);
}
#[Route('/purge-indexes', name: 'purge_indexes', methods: ['POST'])]
public function purgeIndexes(MeilisearchService $meilisearch): Response
{
$meilisearch->purgeAllIndexes();
$this->addFlash('success', 'Tous les index Meilisearch ont ete purges.');
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/customers', name: 'customers', methods: ['POST'])]
public function syncCustomers(CustomerRepository $customerRepository, MeilisearchService $meilisearch): Response
{
@@ -174,6 +194,23 @@ class SyncController extends AbstractController
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/factures', name: 'factures', methods: ['POST'])]
public function syncFactures(EntityManagerInterface $em, MeilisearchService $meilisearch): Response
{
try {
$meilisearch->setupIndexes();
$items = $em->getRepository(Facture::class)->findAll();
foreach ($items as $item) {
$meilisearch->indexFacture($item);
}
$this->addFlash('success', \count($items).' facture(s) synchronisee(s) dans Meilisearch.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur sync factures : '.$e->getMessage());
}
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/revendeurs', name: 'revendeurs', methods: ['POST'])]
public function syncRevendeurs(RevendeurRepository $revendeurRepository, MeilisearchService $meilisearch): Response
{

View File

@@ -7,7 +7,9 @@ use App\Service\MeilisearchService;
use App\Service\StripePriceService;
use App\Service\TarificationService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -75,4 +77,40 @@ class TarificationController extends AbstractController
return $this->redirectToRoute('app_admin_tarification');
}
#[Route('/purge', name: '_purge', methods: ['POST'])]
public function purge(
PriceAutomaticRepository $repository,
EntityManagerInterface $em,
MeilisearchService $meilisearch,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
): Response {
$prices = $repository->findAll();
$count = \count($prices);
foreach ($prices as $price) {
// Supprimer le produit Stripe si existe
if ('' !== $stripeSk && null !== $price->getStripeId()) {
try {
\Stripe\Stripe::setApiKey($stripeSk);
\Stripe\Product::update($price->getStripeId(), ['active' => false]);
} catch (\Throwable) {
}
}
// Supprimer de Meilisearch
try {
$meilisearch->removePrice($price->getId());
} catch (\Throwable) {
}
$em->remove($price);
}
$em->flush();
$this->addFlash('success', $count.' tarif(s) purge(s) (DB + Stripe desactives + Meilisearch).');
return $this->redirectToRoute('app_admin_tarification');
}
}

View File

@@ -67,7 +67,7 @@ class CspReportController extends AbstractController
private function sendAlert(MailerInterface $mailer, LoggerInterface $logger, array $report, string $documentUri, string $violatedDirective, string $blockedUri, string $sourceFile): void
{
$email = (new Email())
->from('security-notify@siteconseil.fr')
->from('security-notify@e-cosplay.fr')
->to($this->getParameter('admin_email'))
->subject('Alerte Securite : Violation CSP detectee')
->priority(Email::PRIORITY_HIGH)

View File

@@ -94,6 +94,28 @@ class DevisProcessController extends AbstractController
if ('' !== $reason) {
$devis->setRaisonMessage($reason);
}
// Libere le numero de commande
$devis->getOrderNumber()->markAsUnused();
// Annule la submission dans DocuSeal
$submitterId = (int) ($devis->getSubmissionId() ?? '0');
if ($submitterId > 0) {
try {
$submitter = $this->docuSeal->getSubmitterSlug($submitterId);
// Recupere le submission_id via l'API pour archiver
$submitterData = $this->docuSeal->getSubmitterData($submitterId);
if (null !== $submitterData) {
$submissionId = $submitterData['submission_id'] ?? null;
if (null !== $submissionId) {
$this->docuSeal->archiveSubmission((int) $submissionId);
}
}
} catch (\Throwable) {
// Silencieux : le refus est enregistre meme si DocuSeal echoue
}
}
$this->em->flush();
return $this->render('devis/refused.html.twig', [

View File

@@ -13,7 +13,7 @@ use Symfony\Component\Routing\Attribute\Route;
class EmailTrackingController extends AbstractController
{
#[Route('/track/{messageId}/logo_facture.png', name: 'app_email_track', methods: ['GET'])]
#[Route('/track/{messageId}/logo.jpg', name: 'app_email_track', methods: ['GET'])]
public function track(
string $messageId,
EmailTrackingRepository $repository,
@@ -27,7 +27,7 @@ class EmailTrackingController extends AbstractController
$em->flush();
}
$response = new BinaryFileResponse($projectDir.'/public/logo_facture.png');
$response = new BinaryFileResponse($projectDir.'/public/logo.jpg');
$response->headers->set('Content-Type', 'image/jpeg');
$response->headers->set('Cache-Control', 'no-store');

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Controller;
use App\Entity\Facture;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class FactureVerifyController extends AbstractController
{
#[Route('/facture/verify/{id}/{hmac}', name: 'app_facture_verify', requirements: ['id' => '\d+'])]
public function index(int $id, string $hmac, EntityManagerInterface $em): Response
{
$facture = $em->getRepository(Facture::class)->find($id);
if (null === $facture || !hash_equals($facture->getHmac(), $hmac)) {
return $this->render('facture/verify_invalid.html.twig');
}
return $this->render('facture/verify.html.twig', [
'facture' => $facture,
'customer' => $facture->getCustomer(),
]);
}
}

View File

@@ -71,7 +71,7 @@ class ForgotPasswordController extends AbstractController
if (null !== $user) {
$mailer->sendEmail(
$email,
'CRM SITECONSEIL - Code de reinitialisation',
'CRM E-Cosplay - Code de reinitialisation',
$twig->render('emails/forgot_password_code.html.twig', ['code' => $code]),
null,
null,
@@ -106,7 +106,7 @@ class ForgotPasswordController extends AbstractController
$mailer->sendEmail(
$sessionEmail,
'CRM SITECONSEIL - Mot de passe modifie',
'CRM E-Cosplay - Mot de passe modifie',
$twig->render('emails/password_changed.html.twig'),
null,
null,

View File

@@ -113,19 +113,11 @@ class LegalController extends AbstractController
return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR);
}
try {
$result = $rgpd->handleAccessRequest($ip, $email);
// Envoie un code de verification par email avant de generer l'attestation
$rgpd->sendVerificationCode($email, $ip, 'access');
$this->addFlash('success', 'Un code de verification a ete envoye a '.$email.'. Veuillez le saisir ci-dessous pour valider votre demande.');
if ($result['found']) {
$this->addFlash('success', 'Vos donnees ont ete envoyees par email.');
} else {
$this->addFlash('success', 'Aucune donnee trouvee pour cette adresse IP. Un email de confirmation a ete envoye.');
}
} catch (\Throwable) {
$this->addFlash('error', 'Une erreur est survenue lors du traitement de votre demande. Veuillez reessayer ou nous contacter a contact@siteconseil.fr.');
}
return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR);
return $this->redirect($this->generateUrl('app_legal_rgpd_verify', ['type' => 'access', 'email' => $email, 'ip' => $ip]));
}
#[Route('/rgpd/suppression', name: 'rgpd_deletion', methods: ['POST'])]
@@ -140,16 +132,64 @@ class LegalController extends AbstractController
return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR);
}
try {
$result = $rgpd->handleDeletionRequest($ip, $email);
// Verification par code email
$rgpd->sendVerificationCode($email, $ip, 'deletion');
$this->addFlash('success', 'Un code de verification a ete envoye a '.$email.'. Veuillez le saisir ci-dessous pour valider votre demande.');
if ($result['found']) {
$this->addFlash('success', 'Vos donnees ont ete supprimees. Une attestation a ete envoyee par email.');
return $this->redirect($this->generateUrl('app_legal_rgpd_verify', ['type' => 'deletion', 'email' => $email, 'ip' => $ip]));
return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR);
}
#[Route('/rgpd/verify', name: 'rgpd_verify', methods: ['GET', 'POST'])]
public function rgpdVerify(Request $request, RgpdService $rgpd): Response
{
$type = $request->query->getString('type', $request->request->getString('type'));
$email = $request->query->getString('email', $request->request->getString('email'));
$ip = $request->query->getString('ip', $request->request->getString('ip'));
if ('' === $type || '' === $email || '' === $ip) {
$this->addFlash('error', 'Parametres manquants.');
return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR);
}
// GET : affiche le formulaire de saisie du code
if ('GET' === $request->getMethod()) {
return $this->render('legal/rgpd_verify.html.twig', [
'type' => $type,
'email' => $email,
'ip' => $ip,
]);
}
// POST : verifie le code
$code = trim($request->request->getString('code'));
if (!$rgpd->verifyCode($email, $ip, $type, $code)) {
$this->addFlash('error', 'Code invalide ou expire. Veuillez reessayer.');
return $this->render('legal/rgpd_verify.html.twig', [
'type' => $type,
'email' => $email,
'ip' => $ip,
]);
}
// Code valide : executer la demande
try {
if ('access' === $type) {
$result = $rgpd->handleAccessRequest($ip, $email);
$this->addFlash('success', $result['found']
? 'Votre attestation a ete generee et signee. Elle sera envoyee sur votre boite mail '.$email.'.'
: 'Aucune donnee trouvee pour cette adresse IP. Une attestation d\'absence de donnees sera envoyee sur votre boite mail '.$email.'.');
} else {
$this->addFlash('success', 'Aucune donnee trouvee pour cette adresse IP.');
$result = $rgpd->handleDeletionRequest($ip, $email);
$this->addFlash('success', $result['found']
? 'Vos donnees ont ete supprimees. Une attestation a ete envoyee par email.'
: 'Aucune donnee trouvee pour cette adresse IP.');
}
} catch (\Throwable) {
$this->addFlash('error', 'Une erreur est survenue lors du traitement de votre demande. Veuillez reessayer ou nous contacter a contact@siteconseil.fr.');
$this->addFlash('error', 'Une erreur est survenue lors du traitement de votre demande. Veuillez reessayer ou nous contacter a contact@e-cosplay.fr.');
}
return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR);

View File

@@ -3,22 +3,434 @@
namespace App\Controller;
use App\Entity\Advert;
use App\Entity\AdvertEvent;
use App\Entity\Revendeur;
use App\Service\MailerService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
class OrderPaymentController extends AbstractController
{
private const NOTIFICATION_EMAIL = 'notification@e-cosplay.fr';
public function __construct(
private EntityManagerInterface $em,
private MailerService $mailer,
private Environment $twig,
) {
}
#[Route('/order/{numOrder}', name: 'app_order_payment', requirements: ['numOrder' => '.+'])]
public function index(string $numOrder, EntityManagerInterface $em): Response
public function index(
string $numOrder,
\Symfony\Component\HttpFoundation\Request $request,
#[Autowire(env: 'STRIPE_PK')] string $stripePk = '',
): Response {
$advert = $this->findAdvert($numOrder);
$customer = $advert->getCustomer();
$session = $request->getSession();
$sessionKey = 'order_verified_'.$advert->getId();
// Verifier si deja authentifie pour cet avis
if (!$session->get($sessionKey, false)) {
return $this->redirectToRoute('app_order_verify', ['numOrder' => $numOrder]);
}
// Tracker la visite
$event = new AdvertEvent($advert, AdvertEvent::TYPE_VIEW, 'Page paiement consultee', $request->getClientIp());
$this->em->persist($event);
$this->em->flush();
return $this->render('order/payment.html.twig', [
'advert' => $advert,
'customer' => $customer,
'stripe_pk' => $stripePk,
]);
}
#[Route('/order-verify/{numOrder}', name: 'app_order_verify', requirements: ['numOrder' => '.+'])]
public function verify(
string $numOrder,
\Symfony\Component\HttpFoundation\Request $request,
): Response {
$advert = $this->findAdvert($numOrder);
$customer = $advert->getCustomer();
$session = $request->getSession();
$sessionKey = 'order_verified_'.$advert->getId();
// Deja verifie
if ($session->get($sessionKey, false)) {
return $this->redirectToRoute('app_order_payment', ['numOrder' => $numOrder]);
}
if (null === $customer || null === $customer->getEmail()) {
// Pas de client/email : acces direct (fallback)
$session->set($sessionKey, true);
return $this->redirectToRoute('app_order_payment', ['numOrder' => $numOrder]);
}
$codeKey = 'order_code_'.$advert->getId();
$codeExpiresKey = 'order_code_expires_'.$advert->getId();
$error = null;
if ('POST' === $request->getMethod()) {
$code = trim($request->request->getString('code'));
$storedCode = $session->get($codeKey);
$expiresAt = $session->get($codeExpiresKey, 0);
if (time() > $expiresAt) {
$error = 'Code expire. Cliquez sur "Renvoyer le code" pour en recevoir un nouveau.';
$session->remove($codeKey);
$session->remove($codeExpiresKey);
} elseif ($code === $storedCode) {
// Code valide
$session->set($sessionKey, true);
$session->remove($codeKey);
$session->remove($codeExpiresKey);
return $this->redirectToRoute('app_order_payment', ['numOrder' => $numOrder]);
} else {
$error = 'Code incorrect. Veuillez reessayer.';
}
}
// Envoyer le code si pas encore envoye ou expire
if (null === $session->get($codeKey) || time() > $session->get($codeExpiresKey, 0)) {
$code = str_pad((string) random_int(0, 999999), 6, '0', \STR_PAD_LEFT);
$session->set($codeKey, $code);
$session->set($codeExpiresKey, time() + 900); // 15 minutes
$this->mailer->sendEmail(
$customer->getEmail(),
'Code de verification - Avis '.$advert->getOrderNumber()->getNumOrder(),
$this->twig->render('emails/order_verify_code.html.twig', [
'customer' => $customer,
'advert' => $advert,
'code' => $code,
]),
null,
null,
false,
);
}
return $this->render('order/verify.html.twig', [
'advert' => $advert,
'customer' => $customer,
'error' => $error,
'numOrder' => $numOrder,
]);
}
#[Route('/order-verify/{numOrder}/resend', name: 'app_order_verify_resend', requirements: ['numOrder' => '.+'], methods: ['POST'])]
public function verifyResend(
string $numOrder,
\Symfony\Component\HttpFoundation\Request $request,
): Response {
$advert = $this->findAdvert($numOrder);
$customer = $advert->getCustomer();
$session = $request->getSession();
if (null !== $customer && null !== $customer->getEmail()) {
$code = str_pad((string) random_int(0, 999999), 6, '0', \STR_PAD_LEFT);
$session->set('order_code_'.$advert->getId(), $code);
$session->set('order_code_expires_'.$advert->getId(), time() + 900);
$this->mailer->sendEmail(
$customer->getEmail(),
'Nouveau code de verification - Avis '.$advert->getOrderNumber()->getNumOrder(),
$this->twig->render('emails/order_verify_code.html.twig', [
'customer' => $customer,
'advert' => $advert,
'code' => $code,
]),
null,
null,
false,
);
}
$this->addFlash('success', 'Un nouveau code a ete envoye.');
return $this->redirectToRoute('app_order_verify', ['numOrder' => $numOrder]);
}
#[Route('/order-choose/virement/{numOrder}', name: 'app_order_payment_virement', requirements: ['numOrder' => '.+'], methods: ['POST'])]
public function chooseVirement(string $numOrder): Response
{
$advert = $em->createQueryBuilder()
$advert = $this->findAdvert($numOrder);
$customer = $advert->getCustomer();
$num = $advert->getOrderNumber()->getNumOrder();
// Mail client : rappel coordonnees bancaires
if (null !== $customer && null !== $customer->getEmail()) {
$this->mailer->sendEmail(
$customer->getEmail(),
'Virement bancaire - Avis de paiement '.$num,
$this->twig->render('emails/payment_virement_client.html.twig', [
'customer' => $customer,
'advert' => $advert,
]),
null,
null,
false,
);
}
// Notification interne
$this->mailer->sendEmail(
self::NOTIFICATION_EMAIL,
'Paiement par virement - '.$num.' - '.($customer?->getFullName() ?? 'Inconnu'),
$this->twig->render('emails/payment_notification_admin.html.twig', [
'customer' => $customer,
'advert' => $advert,
'method' => 'Virement bancaire',
]),
null,
null,
false,
);
return $this->render('order/payment_confirmed.html.twig', [
'advert' => $advert,
'customer' => $customer,
'method' => 'virement',
]);
}
#[Route('/order-choose/cheque/{numOrder}', name: 'app_order_payment_cheque', requirements: ['numOrder' => '.+'], methods: ['POST'])]
public function chooseCheque(string $numOrder): Response
{
$advert = $this->findAdvert($numOrder);
$customer = $advert->getCustomer();
$num = $advert->getOrderNumber()->getNumOrder();
// Mail client : rappel envoi cheque
if (null !== $customer && null !== $customer->getEmail()) {
$this->mailer->sendEmail(
$customer->getEmail(),
'Paiement par cheque - Avis de paiement '.$num,
$this->twig->render('emails/payment_cheque_client.html.twig', [
'customer' => $customer,
'advert' => $advert,
]),
null,
null,
false,
);
}
// Notification interne
$this->mailer->sendEmail(
self::NOTIFICATION_EMAIL,
'Paiement par cheque - '.$num.' - '.($customer?->getFullName() ?? 'Inconnu'),
$this->twig->render('emails/payment_notification_admin.html.twig', [
'customer' => $customer,
'advert' => $advert,
'method' => 'Cheque',
]),
null,
null,
false,
);
return $this->render('order/payment_confirmed.html.twig', [
'advert' => $advert,
'customer' => $customer,
'method' => 'cheque',
]);
}
#[Route('/order-choose/stripe/{numOrder}', name: 'app_order_payment_stripe_intent', requirements: ['numOrder' => '.+'], methods: ['POST'])]
public function createStripeIntent(
string $numOrder,
\Symfony\Component\HttpFoundation\Request $request,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
): JsonResponse {
$advert = $this->findAdvert($numOrder);
$customer = $advert->getCustomer();
if ('' === $stripeSk) {
return $this->json(['error' => 'Stripe non configure'], 500);
}
$amount = (int) round(((float) $advert->getTotalTtc()) * 100);
if ($amount <= 0) {
return $this->json(['error' => 'Montant invalide'], 400);
}
$body = json_decode($request->getContent(), true) ?? [];
$paymentMethod = $body['method'] ?? 'card';
try {
\Stripe\Stripe::setApiKey($stripeSk);
$params = [
'amount' => $amount,
'currency' => 'eur',
'metadata' => [
'advert_id' => $advert->getId(),
'num_order' => $advert->getOrderNumber()->getNumOrder(),
'customer_name' => $customer?->getFullName() ?? 'Inconnu',
'customer_email' => $customer?->getEmail() ?? '',
'payment_method' => $paymentMethod,
],
'description' => 'Avis de paiement '.$advert->getOrderNumber()->getNumOrder(),
];
if ('sepa' === $paymentMethod) {
// Prelevement bancaire : SEPA + virement bancaire Stripe
$params['payment_method_types'] = ['sepa_debit', 'customer_balance'];
} else {
// Paiement en ligne : toutes les methodes sauf SEPA/virement (bouton dedie)
$params['automatic_payment_methods'] = ['enabled' => true];
$params['excluded_payment_method_types'] = ['sepa_debit', 'customer_balance', 'bancontact', 'eps'];
}
if (null !== $customer?->getStripeCustomerId()) {
$params['customer'] = $customer->getStripeCustomerId();
}
if (null !== $customer?->getEmail()) {
$params['receipt_email'] = $customer->getEmail();
}
// Stripe Connect : si le client a un revendeur avec Stripe Connect actif,
// le paiement est lie au compte connecte (transfert automatique)
$revendeur = $this->findRevendeur($customer);
if (null !== $revendeur) {
// > 1000€ : 75% E-Cosplay / 25% revendeur, sinon 50/50
$feeRate = $amount > 100000 ? 0.75 : 0.50;
$applicationFee = (int) round($amount * $feeRate);
$params['application_fee_amount'] = $applicationFee;
$params['transfer_data'] = [
'destination' => $revendeur->getStripeConnectId(),
];
$params['metadata']['revendeur_code'] = $revendeur->getCodeRevendeur();
$params['metadata']['revendeur_connect_id'] = $revendeur->getStripeConnectId();
$params['metadata']['application_fee'] = (string) $applicationFee;
}
$intent = \Stripe\PaymentIntent::create($params);
$advert->setStripePaymentId($intent->id);
$this->em->flush();
return $this->json([
'clientSecret' => $intent->client_secret,
'method' => $paymentMethod,
]);
} catch (\Throwable $e) {
return $this->json(['error' => $e->getMessage()], 500);
}
}
#[Route('/order-choose/stripe/{numOrder}/success', name: 'app_order_payment_stripe_success', requirements: ['numOrder' => '.+'], methods: ['GET'])]
public function stripeSuccess(
string $numOrder,
\Symfony\Component\HttpFoundation\Request $request,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
): Response {
$advert = $this->findAdvert($numOrder);
// Retirer la session de verification apres paiement
$request->getSession()->remove('order_verified_'.$advert->getId());
// Si le webhook a deja traite le paiement, afficher la confirmation directe
if (Advert::STATE_ACCEPTED === $advert->getState()) {
return $this->render('order/payment_confirmed.html.twig', [
'advert' => $advert,
'customer' => $advert->getCustomer(),
'method' => 'stripe',
]);
}
// Verifier le statut du PaymentIntent directement aupres de Stripe
$piId = $advert->getStripePaymentId();
if (null !== $piId && '' !== $stripeSk) {
try {
\Stripe\Stripe::setApiKey($stripeSk);
$pi = \Stripe\PaymentIntent::retrieve($piId);
if ('succeeded' === $pi->status) {
return $this->render('order/payment_confirmed.html.twig', [
'advert' => $advert,
'customer' => $advert->getCustomer(),
'method' => 'stripe',
]);
}
} catch (\Throwable) {
// Fallback sur le loader
}
}
// Sinon afficher le loader de verification (le webhook va traiter en arriere-plan)
$method = $request->query->getString('method', 'card');
return $this->render('order/payment_processing.html.twig', [
'advert' => $advert,
'customer' => $advert->getCustomer(),
'checkUrl' => $this->generateUrl('app_order_payment_stripe_check', ['numOrder' => $numOrder]),
'method' => $method,
]);
}
#[Route('/order-choose/stripe/{numOrder}/check', name: 'app_order_payment_stripe_check', requirements: ['numOrder' => '.+'], methods: ['GET'])]
public function stripeCheck(string $numOrder): JsonResponse
{
$advert = $this->findAdvert($numOrder);
return $this->json([
'status' => $advert->getState(),
'accepted' => Advert::STATE_ACCEPTED === $advert->getState(),
]);
}
/**
* Trouve le revendeur Stripe Connect associe au client (si eligible).
*/
private function findRevendeur(?\App\Entity\Customer $customer): ?Revendeur
{
if (null === $customer) {
return null;
}
$code = $customer->getRevendeurCode();
if (null === $code || '' === $code) {
return null;
}
$revendeur = $this->em->getRepository(Revendeur::class)->findOneBy(['codeRevendeur' => $code]);
if (null === $revendeur) {
return null;
}
// Verifie que le revendeur utilise Stripe Connect et a un ID connecte
if (!$revendeur->isUseStripe() || null === $revendeur->getStripeConnectId() || '' === $revendeur->getStripeConnectId()) {
return null;
}
return $revendeur;
}
private function findAdvert(string $numOrder): Advert
{
$advert = $this->em->createQueryBuilder()
->select('a')
->from(Advert::class, 'a')
->join('a.orderNumber', 'o')
->where('o.numOrder = :num')
->andWhere('a.state != :cancel')
->setParameter('num', $numOrder)
->setParameter('cancel', Advert::STATE_CANCEL)
->orderBy('a.createdAt', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
@@ -26,9 +438,6 @@ class OrderPaymentController extends AbstractController
throw $this->createNotFoundException('Avis de paiement introuvable.');
}
return $this->render('order/payment.html.twig', [
'advert' => $advert,
'customer' => $advert->getCustomer(),
]);
return $advert;
}
}

View File

@@ -21,7 +21,7 @@ use Twig\Environment;
class WebhookDocuSealController extends AbstractController
{
private const ATTESTATION_NOT_FOUND = 'Attestation not found';
private const MONITOR_EMAIL = 'monitor@siteconseil.fr';
private const MONITOR_EMAIL = 'monitor@e-cosplay.fr';
#[Route('/webhooks/docuseal', name: 'app_webhook_docuseal', methods: ['POST'])]
public function __invoke(
@@ -383,7 +383,7 @@ class WebhookDocuSealController extends AbstractController
$mailer->sendEmail(
$attestation->getEmail(),
'CRM SITECONSEIL - Attestation signee '.$typeName.' ('.$attestation->getReference().')',
'CRM E-Cosplay - Attestation signee '.$typeName.' ('.$attestation->getReference().')',
$twig->render('emails/rgpd_attestation_signed.html.twig', [
'attestation' => $attestation,
'typeName' => $typeName,

View File

@@ -2,20 +2,38 @@
namespace App\Controller;
use App\Entity\Advert;
use App\Entity\AdvertPayment;
use App\Entity\Facture;
use App\Entity\StripeWebhookSecret;
use App\Repository\StripeWebhookSecretRepository;
use App\Service\FactureService;
use App\Service\MailerService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
class WebhookStripeController extends AbstractController
{
private const NOTIFICATION_EMAIL = 'notification@e-cosplay.fr';
public function __construct(
private LoggerInterface $logger,
private StripeWebhookSecretRepository $secretRepository,
private EntityManagerInterface $em,
private MailerService $mailer,
private Environment $twig,
private FactureService $factureService,
private KernelInterface $kernel,
private UrlGeneratorInterface $urlGenerator,
) {
}
@@ -68,6 +86,281 @@ class WebhookStripeController extends AbstractController
'type' => $event->type,
]);
return new JsonResponse(['status' => 'ok', 'channel' => $channel, 'event' => $event->type]);
// Dispatch par type d'evenement (main + connect)
return match ($event->type) {
'payment_intent.succeeded' => $this->handlePaymentSucceeded($event, $channel),
'payment_intent.payment_failed' => $this->handlePaymentFailed($event, $channel),
default => new JsonResponse(['status' => 'ok', 'channel' => $channel, 'event' => $event->type]),
};
}
private function handlePaymentSucceeded(\Stripe\Event $event, string $channel): JsonResponse
{
$paymentIntent = $event->data->object;
$metadata = $paymentIntent->metadata instanceof \Stripe\StripeObject ? $paymentIntent->metadata->toArray() : (array) ($paymentIntent->metadata ?? []);
$advertId = $metadata['advert_id'] ?? null;
if (null === $advertId) {
$this->logger->info('Stripe payment_intent.succeeded ['.$channel.']: pas de advert_id dans metadata', ['metadata' => $metadata]);
return new JsonResponse(['status' => 'ok', 'action' => 'no_advert']);
}
$advert = $this->em->getRepository(Advert::class)->find((int) $advertId);
if (null === $advert) {
$this->logger->warning('Stripe payment_intent.succeeded ['.$channel.']: advert introuvable', ['advert_id' => $advertId]);
return new JsonResponse(['status' => 'ok', 'action' => 'advert_not_found']);
}
// Eviter les doublons (webhook recu sur main ET connect pour le meme paiement)
if (Advert::STATE_ACCEPTED === $advert->getState() && $advert->getStripePaymentId() === $paymentIntent->id) {
$this->logger->info('Stripe payment_intent.succeeded ['.$channel.']: deja traite', ['pi' => $paymentIntent->id]);
return new JsonResponse(['status' => 'ok', 'action' => 'already_processed']);
}
$amount = number_format($paymentIntent->amount_received / 100, 2, '.', '');
$method = $metadata['payment_method'] ?? ($paymentIntent->payment_method_types[0] ?? null);
$isConnect = str_contains($channel, 'connect');
$revendeurCode = $metadata['revendeur_code'] ?? null;
// Determiner le label methode pour les mails
$methodLabel = match ($method) {
'sepa_debit' => 'Prelevement SEPA',
'customer_balance' => 'Virement bancaire (Stripe)',
'paypal' => 'PayPal',
'klarna' => 'Klarna',
'revolut_pay' => 'Revolut Pay',
'amazon_pay' => 'Amazon Pay',
'link' => 'Link (Stripe)',
default => 'Carte bancaire',
};
// Creer l'AdvertPayment
$payment = new AdvertPayment($advert, AdvertPayment::TYPE_SUCCESS, $amount);
$payment->setMethod($method);
$this->em->persist($payment);
// Mettre a jour l'etat de l'avis
$advert->setState(Advert::STATE_ACCEPTED);
$advert->setStripePaymentId($paymentIntent->id);
// Tracker l'evenement paiement
$this->em->persist(new \App\Entity\AdvertEvent($advert, \App\Entity\AdvertEvent::TYPE_PAY, $methodLabel.' - '.$amount.' EUR'));
$this->em->flush();
// Generation automatique de la facture si montant paye == montant avis
$facture = null;
try {
$facture = $this->factureService->createPaidFactureFromAdvert($advert, $amount, $methodLabel);
// Generer le PDF + envoyer au client
if (null !== $facture) {
$this->generateAndSendFacture($facture);
}
} catch (\Throwable $e) {
$this->logger->error('Stripe webhook: erreur generation facture: '.$e->getMessage());
}
$customer = $advert->getCustomer();
$numOrder = $advert->getOrderNumber()->getNumOrder();
// Mail client : confirmation paiement
if (null !== $customer && null !== $customer->getEmail()) {
try {
$this->mailer->sendEmail(
$customer->getEmail(),
'Paiement confirme - Avis '.$numOrder,
$this->twig->render('emails/payment_stripe_success_client.html.twig', [
'customer' => $customer,
'advert' => $advert,
'amount' => $amount,
'methodLabel' => $methodLabel,
]),
null,
null,
false,
);
} catch (\Throwable $e) {
$this->logger->error('Stripe webhook: erreur envoi mail client: '.$e->getMessage());
}
}
// Notification admin
$adminSubject = ($isConnect ? '[Connect] ' : '').'Paiement recu ('.$methodLabel.') - '.$numOrder.' - '.($customer?->getFullName() ?? 'Inconnu');
if (null !== $revendeurCode) {
$adminSubject .= ' [Revendeur '.$revendeurCode.']';
}
try {
$this->mailer->sendEmail(
self::NOTIFICATION_EMAIL,
$adminSubject,
$this->twig->render('emails/payment_notification_admin.html.twig', [
'customer' => $customer,
'advert' => $advert,
'method' => $methodLabel.' - '.$amount.' EUR'.($isConnect && null !== $revendeurCode ? ' (via revendeur '.$revendeurCode.')' : ''),
]),
null,
null,
false,
);
} catch (\Throwable $e) {
$this->logger->error('Stripe webhook: erreur envoi mail admin: '.$e->getMessage());
}
$this->logger->info('Stripe payment_intent.succeeded ['.$channel.']: advert '.$numOrder.' -> accepted, '.$amount.' EUR, methode '.$method.($isConnect ? ' (connect)' : ''));
return new JsonResponse(['status' => 'ok', 'action' => 'payment_accepted', 'advert' => $numOrder, 'amount' => $amount, 'method' => $method, 'channel' => $channel]);
}
private function handlePaymentFailed(\Stripe\Event $event, string $channel): JsonResponse
{
$paymentIntent = $event->data->object;
$metadata = $paymentIntent->metadata instanceof \Stripe\StripeObject ? $paymentIntent->metadata->toArray() : (array) ($paymentIntent->metadata ?? []);
$advertId = $metadata['advert_id'] ?? null;
if (null === $advertId) {
return new JsonResponse(['status' => 'ok', 'action' => 'no_advert']);
}
$advert = $this->em->getRepository(Advert::class)->find((int) $advertId);
if (null === $advert) {
return new JsonResponse(['status' => 'ok', 'action' => 'advert_not_found']);
}
$amount = number_format(($paymentIntent->amount ?? 0) / 100, 2, '.', '');
$method = $metadata['payment_method'] ?? ($paymentIntent->payment_method_types[0] ?? null);
$errorMessage = $paymentIntent->last_payment_error?->message ?? 'Paiement refuse';
$methodLabel = match ($method) {
'sepa_debit' => 'Prelevement SEPA',
'customer_balance' => 'Virement bancaire (Stripe)',
'paypal' => 'PayPal',
default => 'Carte bancaire',
};
// Creer l'AdvertPayment refused
$payment = new AdvertPayment($advert, AdvertPayment::TYPE_REFUSED, $amount);
$payment->setMethod($method);
$this->em->persist($payment);
$this->em->flush();
$customer = $advert->getCustomer();
$numOrder = $advert->getOrderNumber()->getNumOrder();
$revendeurCode = $metadata['revendeur_code'] ?? null;
$isConnect = str_contains($channel, 'connect');
// Mail client : notification du refus de paiement
if (null !== $customer && null !== $customer->getEmail()) {
try {
$this->mailer->sendEmail(
$customer->getEmail(),
'Paiement refuse - Avis '.$numOrder,
$this->twig->render('emails/payment_stripe_failed_client.html.twig', [
'customer' => $customer,
'advert' => $advert,
'amount' => $amount,
'methodLabel' => $methodLabel,
'errorMessage' => $errorMessage,
]),
null,
null,
false,
);
} catch (\Throwable $e) {
$this->logger->error('Stripe webhook: erreur envoi mail client echec: '.$e->getMessage());
}
}
// Notification admin
try {
$this->mailer->sendEmail(
self::NOTIFICATION_EMAIL,
($isConnect ? '[Connect] ' : '').'Paiement echoue ('.$methodLabel.') - '.$numOrder.' - '.($customer?->getFullName() ?? 'Inconnu').($revendeurCode ? ' [Revendeur '.$revendeurCode.']' : ''),
$this->twig->render('emails/payment_notification_admin.html.twig', [
'customer' => $customer,
'advert' => $advert,
'method' => $methodLabel.' refuse - '.$errorMessage,
]),
null,
null,
false,
);
} catch (\Throwable $e) {
$this->logger->error('Stripe webhook: erreur envoi mail admin echec: '.$e->getMessage());
}
$this->logger->warning('Stripe payment_intent.payment_failed ['.$channel.']: advert '.$numOrder.' - '.$methodLabel.' - '.$errorMessage);
return new JsonResponse(['status' => 'ok', 'action' => 'payment_failed', 'advert' => $numOrder, 'method' => $method]);
}
/**
* Genere le PDF de la facture, le sauvegarde via Vich, et l'envoie au client par mail.
*/
private function generateAndSendFacture(Facture $facture): void
{
$customer = $facture->getCustomer();
// 1. Generer le PDF via FPDF
$pdf = new \App\Service\Pdf\FacturePdf($this->kernel, $facture, $this->urlGenerator, $this->twig);
$pdf->generate();
$tmpPath = tempnam(sys_get_temp_dir(), 'facture_').'.pdf';
$pdf->Output('F', $tmpPath);
$uploadedFile = new UploadedFile(
$tmpPath,
'facture-'.str_replace('/', '-', $facture->getInvoiceNumber()).'.pdf',
'application/pdf',
null,
true
);
$facture->setFacturePdfFile($uploadedFile);
$facture->setUpdatedAt(new \DateTimeImmutable());
$facture->setState(Facture::STATE_SEND);
$this->em->flush();
@unlink($tmpPath);
$this->logger->info('Stripe webhook: PDF facture '.$facture->getInvoiceNumber().' genere');
// 2. Envoyer au client
if (null === $customer || null === $customer->getEmail()) {
return;
}
$projectDir = $this->kernel->getProjectDir();
$attachments = [];
if (null !== $facture->getFacturePdf()) {
$pdfPath = $projectDir.'/public/uploads/factures/'.$facture->getFacturePdf();
if (file_exists($pdfPath)) {
$attachments[] = ['path' => $pdfPath, 'name' => 'facture-'.str_replace('/', '-', $facture->getInvoiceNumber()).'.pdf'];
}
}
$verifyUrl = $this->urlGenerator->generate('app_facture_verify', [
'id' => $facture->getId(),
'hmac' => $facture->getHmac(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$this->mailer->sendEmail(
$customer->getEmail(),
'Facture '.$facture->getInvoiceNumber(),
$this->twig->render('emails/facture_send.html.twig', [
'customer' => $customer,
'facture' => $facture,
'verifyUrl' => $verifyUrl,
]),
null,
null,
false,
$attachments,
);
$this->logger->info('Stripe webhook: facture '.$facture->getInvoiceNumber().' envoyee a '.$customer->getEmail());
}
}

193
src/Entity/ActionLog.php Normal file
View File

@@ -0,0 +1,193 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Index(columns: ['customer_id'], name: 'idx_action_log_customer')]
#[ORM\Index(columns: ['action'], name: 'idx_action_log_action')]
#[ORM\Index(columns: ['created_at'], name: 'idx_action_log_created')]
class ActionLog
{
public const ACTION_SUSPEND_CUSTOMER = 'suspend_customer';
public const ACTION_UNSUSPEND_CUSTOMER = 'unsuspend_customer';
public const ACTION_SUSPEND_WEBSITE = 'suspend_website';
public const ACTION_UNSUSPEND_WEBSITE = 'unsuspend_website';
public const ACTION_SUSPEND_DOMAIN_EMAIL = 'suspend_domain_email';
public const ACTION_UNSUSPEND_DOMAIN_EMAIL = 'unsuspend_domain_email';
public const ACTION_DISABLE_CUSTOMER = 'disable_customer';
public const ACTION_DELETE_CUSTOMER_DATA = 'delete_customer_data';
public const ACTION_FORMAL_NOTICE = 'formal_notice';
public const ACTION_TERMINATION = 'termination';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 50)]
private string $action;
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Customer $customer = null;
#[ORM\Column(nullable: true)]
private ?int $entityId = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $entityType = null;
#[ORM\Column(type: 'text')]
private string $message;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $context = null;
#[ORM\Column(length: 20)]
private string $severity;
#[ORM\Column(length: 50, nullable: true)]
private ?string $previousState = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $newState = null;
#[ORM\Column]
private bool $success;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $errorMessage = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct(
string $action,
string $message,
string $severity = 'info',
bool $success = true,
) {
$this->action = $action;
$this->message = $message;
$this->severity = $severity;
$this->success = $success;
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getAction(): string
{
return $this->action;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function setCustomer(?Customer $customer): static
{
$this->customer = $customer;
return $this;
}
public function getEntityId(): ?int
{
return $this->entityId;
}
public function setEntityId(?int $entityId): static
{
$this->entityId = $entityId;
return $this;
}
public function getEntityType(): ?string
{
return $this->entityType;
}
public function setEntityType(?string $entityType): static
{
$this->entityType = $entityType;
return $this;
}
public function getMessage(): string
{
return $this->message;
}
public function getContext(): ?string
{
return $this->context;
}
public function setContext(?string $context): static
{
$this->context = $context;
return $this;
}
public function getSeverity(): string
{
return $this->severity;
}
public function getPreviousState(): ?string
{
return $this->previousState;
}
public function setPreviousState(?string $previousState): static
{
$this->previousState = $previousState;
return $this;
}
public function getNewState(): ?string
{
return $this->newState;
}
public function setNewState(?string $newState): static
{
$this->newState = $newState;
return $this;
}
public function isSuccess(): bool
{
return $this->success;
}
public function getErrorMessage(): ?string
{
return $this->errorMessage;
}
public function setErrorMessage(?string $errorMessage): static
{
$this->errorMessage = $errorMessage;
$this->success = false;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -57,6 +57,9 @@ class Advert
#[ORM\Column(length: 255, nullable: true)]
private ?string $submissionId = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $stripePaymentId = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $advertFile = null;
@@ -78,12 +81,18 @@ class Advert
#[ORM\OrderBy(['pos' => 'ASC'])]
private Collection $lines;
/** @var Collection<int, AdvertPayment> */
#[ORM\OneToMany(targetEntity: AdvertPayment::class, mappedBy: 'advert', cascade: ['persist', 'remove'])]
#[ORM\OrderBy(['createdAt' => 'DESC'])]
private Collection $payments;
public function __construct(OrderNumber $orderNumber, string $hmacSecret)
{
$this->orderNumber = $orderNumber;
$this->createdAt = new \DateTimeImmutable();
$this->factures = new ArrayCollection();
$this->lines = new ArrayCollection();
$this->payments = new ArrayCollection();
$this->hmac = $this->generateHmac($hmacSecret);
}
@@ -182,6 +191,16 @@ class Advert
$this->submissionId = $submissionId;
}
public function getStripePaymentId(): ?string
{
return $this->stripePaymentId;
}
public function setStripePaymentId(?string $stripePaymentId): void
{
$this->stripePaymentId = $stripePaymentId;
}
public function getAdvertFile(): ?string
{
return $this->advertFile;
@@ -250,6 +269,12 @@ class Advert
return $this;
}
/** @return Collection<int, AdvertPayment> */
public function getPayments(): Collection
{
return $this->payments;
}
public function verifyHmac(string $hmacSecret): bool
{
return hash_equals($this->hmac, $this->generateHmac($hmacSecret));

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Index(columns: ['advert_id'], name: 'idx_advert_event_advert')]
#[ORM\Index(columns: ['type'], name: 'idx_advert_event_type')]
class AdvertEvent
{
public const TYPE_VIEW = 'view';
public const TYPE_PAY = 'pay';
public const TYPE_MAIL_OPEN = 'mail_open';
public const TYPE_MAIL_SEND = 'mail_send';
public const TYPE_REMINDER = 'reminder';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Advert::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Advert $advert;
#[ORM\Column(length: 30)]
private string $type;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $details = null;
#[ORM\Column(length: 45, nullable: true)]
private ?string $ip = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct(Advert $advert, string $type, ?string $details = null, ?string $ip = null)
{
$this->advert = $advert;
$this->type = $type;
$this->details = $details;
$this->ip = $ip;
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getAdvert(): Advert
{
return $this->advert;
}
public function getType(): string
{
return $this->type;
}
public function getDetails(): ?string
{
return $this->details;
}
public function getIp(): ?string
{
return $this->ip;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getTypeLabel(): string
{
return match ($this->type) {
self::TYPE_VIEW => 'Page consultee',
self::TYPE_PAY => 'Paiement effectue',
self::TYPE_MAIL_OPEN => 'Email ouvert',
self::TYPE_MAIL_SEND => 'Email envoye',
self::TYPE_REMINDER => 'Relance envoyee',
default => $this->type,
};
}
}

View File

@@ -28,6 +28,12 @@ class AdvertLine
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
private string $priceHt = '0.00';
#[ORM\Column(length: 30, nullable: true)]
private ?string $type = null;
#[ORM\Column(nullable: true)]
private ?int $serviceId = null;
public function __construct(Advert $advert, string $title, string $priceHt = '0.00', int $pos = 0)
{
$this->advert = $advert;
@@ -93,4 +99,28 @@ class AdvertLine
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(?string $type): static
{
$this->type = $type;
return $this;
}
public function getServiceId(): ?int
{
return $this->serviceId;
}
public function setServiceId(?int $serviceId): static
{
$this->serviceId = $serviceId;
return $this;
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Index(columns: ['advert_id'], name: 'idx_advert_payment_advert')]
#[ORM\Index(columns: ['type'], name: 'idx_advert_payment_type')]
class AdvertPayment
{
public const TYPE_SUCCESS = 'success';
public const TYPE_REFUSED = 'refused';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Advert::class, inversedBy: 'payments')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Advert $advert;
#[ORM\Column(length: 20)]
private string $type;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private string $amount;
#[ORM\Column(length: 30, nullable: true)]
private ?string $method = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct(Advert $advert, string $type, string $amount)
{
$this->advert = $advert;
$this->type = $type;
$this->amount = $amount;
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getAdvert(): Advert
{
return $this->advert;
}
public function getType(): string
{
return $this->type;
}
public function getAmount(): string
{
return $this->amount;
}
public function getMethod(): ?string
{
return $this->method;
}
public function setMethod(?string $method): static
{
$this->method = $method;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -150,7 +150,6 @@ class Customer
public function generateCodeComptable(): string
{
$prefix = '411';
$idPart = str_pad((string) ($this->id ?? 0), 4, '0', STR_PAD_LEFT);
$namePart = '';
@@ -164,7 +163,7 @@ class Customer
$namePart = str_pad($namePart, 5, 'X');
return $prefix.'_'.$idPart.'_'.$namePart;
return 'EC-'.$idPart.'-'.$namePart;
}
public function getId(): ?int

View File

@@ -28,6 +28,12 @@ class DevisLine
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
private string $priceHt = '0.00';
#[ORM\Column(length: 30, nullable: true)]
private ?string $type = null;
#[ORM\Column(nullable: true)]
private ?int $serviceId = null;
public function __construct(Devis $devis, string $title, string $priceHt = '0.00', int $pos = 0)
{
$this->devis = $devis;
@@ -93,4 +99,28 @@ class DevisLine
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(?string $type): static
{
$this->type = $type;
return $this;
}
public function getServiceId(): ?int
{
return $this->serviceId;
}
public function setServiceId(?int $serviceId): static
{
$this->serviceId = $serviceId;
return $this;
}
}

View File

@@ -3,11 +3,21 @@
namespace App\Entity;
use App\Repository\FactureRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Attribute as Vich;
#[ORM\Entity(repositoryClass: FactureRepository::class)]
#[Vich\Uploadable]
class Facture
{
public const STATE_CREATED = 'created';
public const STATE_SEND = 'send';
public const STATE_PAID = 'paid';
public const STATE_CANCEL = 'cancel';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -21,6 +31,10 @@ class Facture
#[ORM\JoinColumn(nullable: true)]
private ?Advert $advert = null;
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Customer $customer = null;
/**
* Suffixe pour les factures multiples sur un meme advert (-1, -2, etc.).
*/
@@ -30,13 +44,49 @@ class Facture
#[ORM\Column(length: 128)]
private string $hmac;
#[ORM\Column(length: 20, options: ['default' => 'created'])]
private string $state = self::STATE_CREATED;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
private string $totalHt = '0.00';
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
private string $totalTva = '0.00';
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
private string $totalTtc = '0.00';
#[ORM\Column]
private bool $isPaid = false;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $paidAt = null;
#[ORM\Column(length: 30, nullable: true)]
private ?string $paidMethod = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $facturePdf = null;
#[Vich\UploadableField(mapping: 'facture_pdf', fileNameProperty: 'facturePdf')]
private ?File $facturePdfFile = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
/** @var Collection<int, FactureLine> */
#[ORM\OneToMany(targetEntity: FactureLine::class, mappedBy: 'facture', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['pos' => 'ASC'])]
private Collection $lines;
public function __construct(OrderNumber $orderNumber, string $hmacSecret)
{
$this->orderNumber = $orderNumber;
$this->createdAt = new \DateTimeImmutable();
$this->lines = new ArrayCollection();
$this->hmac = $this->generateHmac($hmacSecret);
}
@@ -60,6 +110,16 @@ class Facture
$this->advert = $advert;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function setCustomer(?Customer $customer): void
{
$this->customer = $customer;
}
public function getSplitIndex(): int
{
return $this->splitIndex;
@@ -86,11 +146,138 @@ class Facture
return $this->hmac;
}
public function getState(): string
{
return $this->state;
}
public function setState(string $state): void
{
$this->state = $state;
}
public function getTotalHt(): string
{
return $this->totalHt;
}
public function setTotalHt(string $totalHt): void
{
$this->totalHt = $totalHt;
}
public function getTotalTva(): string
{
return $this->totalTva;
}
public function setTotalTva(string $totalTva): void
{
$this->totalTva = $totalTva;
}
public function getTotalTtc(): string
{
return $this->totalTtc;
}
public function setTotalTtc(string $totalTtc): void
{
$this->totalTtc = $totalTtc;
}
public function isPaid(): bool
{
return $this->isPaid;
}
public function setIsPaid(bool $isPaid): void
{
$this->isPaid = $isPaid;
}
public function getPaidAt(): ?\DateTimeImmutable
{
return $this->paidAt;
}
public function setPaidAt(?\DateTimeImmutable $paidAt): void
{
$this->paidAt = $paidAt;
}
public function getPaidMethod(): ?string
{
return $this->paidMethod;
}
public function setPaidMethod(?string $paidMethod): void
{
$this->paidMethod = $paidMethod;
}
public function getFacturePdf(): ?string
{
return $this->facturePdf;
}
public function setFacturePdf(?string $facturePdf): void
{
$this->facturePdf = $facturePdf;
}
public function getFacturePdfFile(): ?File
{
return $this->facturePdfFile;
}
public function setFacturePdfFile(?File $facturePdfFile): void
{
$this->facturePdfFile = $facturePdfFile;
if (null !== $facturePdfFile) {
$this->updatedAt = new \DateTimeImmutable();
}
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static
{
$this->updatedAt = $updatedAt;
return $this;
}
/** @return Collection<int, FactureLine> */
public function getLines(): Collection
{
return $this->lines;
}
public function addLine(FactureLine $line): static
{
if (!$this->lines->contains($line)) {
$this->lines->add($line);
}
return $this;
}
public function removeLine(FactureLine $line): static
{
$this->lines->removeElement($line);
return $this;
}
public function verifyHmac(string $hmacSecret): bool
{
return hash_equals($this->hmac, $this->generateHmac($hmacSecret));

126
src/Entity/FactureLine.php Normal file
View File

@@ -0,0 +1,126 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class FactureLine
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Facture::class, inversedBy: 'lines')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Facture $facture;
#[ORM\Column]
private int $pos = 0;
#[ORM\Column(length: 255)]
private string $title;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
private string $priceHt = '0.00';
#[ORM\Column(length: 30, nullable: true)]
private ?string $type = null;
#[ORM\Column(nullable: true)]
private ?int $serviceId = null;
public function __construct(Facture $facture, string $title, string $priceHt = '0.00', int $pos = 0)
{
$this->facture = $facture;
$this->title = $title;
$this->priceHt = $priceHt;
$this->pos = $pos;
}
public function getId(): ?int
{
return $this->id;
}
public function getFacture(): Facture
{
return $this->facture;
}
public function getPos(): int
{
return $this->pos;
}
public function setPos(int $pos): static
{
$this->pos = $pos;
return $this;
}
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getPriceHt(): string
{
return $this->priceHt;
}
public function setPriceHt(string $priceHt): static
{
$this->priceHt = $priceHt;
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(?string $type): static
{
$this->type = $type;
return $this;
}
public function getServiceId(): ?int
{
return $this->serviceId;
}
public function setServiceId(?int $serviceId): static
{
$this->serviceId = $serviceId;
return $this;
}
}

View File

@@ -0,0 +1,203 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Attribute as Vich;
#[ORM\Entity]
#[ORM\Index(columns: ['prestataire_id', 'year', 'month'], name: 'idx_facture_presta_period')]
#[Vich\Uploadable]
class FacturePrestataire
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Prestataire::class, inversedBy: 'factures')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Prestataire $prestataire;
#[ORM\Column(length: 100)]
private string $numFacture;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
private string $montantHt = '0.00';
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
private string $montantTtc = '0.00';
#[ORM\Column(type: 'smallint')]
private int $year;
#[ORM\Column(type: 'smallint')]
private int $month;
#[ORM\Column]
private bool $isPaid = false;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $paidAt = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $facturePdf = null;
#[Vich\UploadableField(mapping: 'facture_prestataire_pdf', fileNameProperty: 'facturePdf')]
private ?File $facturePdfFile = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
public function __construct(Prestataire $prestataire, string $numFacture, int $year, int $month)
{
$this->prestataire = $prestataire;
$this->numFacture = $numFacture;
$this->year = $year;
$this->month = $month;
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getPrestataire(): Prestataire
{
return $this->prestataire;
}
public function getNumFacture(): string
{
return $this->numFacture;
}
public function setNumFacture(string $numFacture): static
{
$this->numFacture = $numFacture;
return $this;
}
public function getMontantHt(): string
{
return $this->montantHt;
}
public function setMontantHt(string $montantHt): static
{
$this->montantHt = $montantHt;
return $this;
}
public function getMontantTtc(): string
{
return $this->montantTtc;
}
public function setMontantTtc(string $montantTtc): static
{
$this->montantTtc = $montantTtc;
return $this;
}
public function getYear(): int
{
return $this->year;
}
public function setYear(int $year): static
{
$this->year = $year;
return $this;
}
public function getMonth(): int
{
return $this->month;
}
public function setMonth(int $month): static
{
$this->month = $month;
return $this;
}
/**
* Label du mois en francais (ex: "Avril 2026").
*/
public function getPeriodLabel(): string
{
$months = [1 => 'Janvier', 'Fevrier', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Aout', 'Septembre', 'Octobre', 'Novembre', 'Decembre'];
return ($months[$this->month] ?? $this->month).' '.$this->year;
}
public function isPaid(): bool
{
return $this->isPaid;
}
public function setIsPaid(bool $isPaid): static
{
$this->isPaid = $isPaid;
return $this;
}
public function getPaidAt(): ?\DateTimeImmutable
{
return $this->paidAt;
}
public function setPaidAt(?\DateTimeImmutable $paidAt): static
{
$this->paidAt = $paidAt;
return $this;
}
public function getFacturePdf(): ?string
{
return $this->facturePdf;
}
public function setFacturePdf(?string $facturePdf): static
{
$this->facturePdf = $facturePdf;
return $this;
}
public function getFacturePdfFile(): ?File
{
return $this->facturePdfFile;
}
public function setFacturePdfFile(?File $facturePdfFile): void
{
$this->facturePdfFile = $facturePdfFile;
if (null !== $facturePdfFile) {
$this->updatedAt = new \DateTimeImmutable();
}
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Index(columns: ['advert_id'], name: 'idx_payment_reminder_advert')]
#[ORM\Index(columns: ['step'], name: 'idx_payment_reminder_step')]
class PaymentReminder
{
// Etapes de relance (jour apres envoi de l'avis)
public const STEP_REMINDER_15 = 'reminder_15'; // J+15 : Rappel
public const STEP_WARNING_10 = 'warning_10'; // J+10 : Rappel + avertissement
public const STEP_SUSPENSION_WARNING_5 = 'suspension_5'; // J+5 : Avertissement suspension services
public const STEP_FINAL_REMINDER_3 = 'final_reminder_3'; // J+3 : Ultime rappel
public const STEP_SUSPENSION_1 = 'suspension_1'; // J+1 : Suspension des services
public const STEP_FORMAL_NOTICE = 'formal_notice'; // J-1 (apres echeance) : Mise en demeure
public const STEP_TERMINATION_WARNING = 'termination_15'; // J+15 post-echeance : Avertissement resiliation + suppression donnees
public const STEP_TERMINATION = 'termination_30'; // J+30 post-echeance : Resiliation + suppression + recouvrement legal
public const STEPS_CONFIG = [
self::STEP_REMINDER_15 => ['days' => 15, 'label' => 'Rappel de paiement', 'severity' => 'info'],
self::STEP_WARNING_10 => ['days' => 20, 'label' => 'Rappel + avertissement', 'severity' => 'warning'],
self::STEP_SUSPENSION_WARNING_5 => ['days' => 25, 'label' => 'Avertissement suspension services', 'severity' => 'danger'],
self::STEP_FINAL_REMINDER_3 => ['days' => 27, 'label' => 'Ultime rappel', 'severity' => 'danger'],
self::STEP_SUSPENSION_1 => ['days' => 29, 'label' => 'Suspension des services', 'severity' => 'critical'],
self::STEP_FORMAL_NOTICE => ['days' => 31, 'label' => 'Mise en demeure', 'severity' => 'critical'],
self::STEP_TERMINATION_WARNING => ['days' => 45, 'label' => 'Avertissement resiliation + suppression donnees', 'severity' => 'critical'],
self::STEP_TERMINATION => ['days' => 60, 'label' => 'Resiliation contrat + recouvrement legal', 'severity' => 'critical'],
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Advert::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Advert $advert;
#[ORM\Column(length: 30)]
private string $step;
#[ORM\Column]
private \DateTimeImmutable $sentAt;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $details = null;
public function __construct(Advert $advert, string $step, ?string $details = null)
{
$this->advert = $advert;
$this->step = $step;
$this->details = $details;
$this->sentAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getAdvert(): Advert
{
return $this->advert;
}
public function getStep(): string
{
return $this->step;
}
public function getSentAt(): \DateTimeImmutable
{
return $this->sentAt;
}
public function getDetails(): ?string
{
return $this->details;
}
public function getStepLabel(): string
{
return self::STEPS_CONFIG[$this->step]['label'] ?? $this->step;
}
public function getSeverity(): string
{
return self::STEPS_CONFIG[$this->step]['severity'] ?? 'info';
}
}

209
src/Entity/Prestataire.php Normal file
View File

@@ -0,0 +1,209 @@
<?php
namespace App\Entity;
use App\Repository\PrestataireRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PrestataireRepository::class)]
class Prestataire
{
public const STATE_ACTIVE = 'active';
public const STATE_INACTIVE = 'inactive';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $raisonSociale;
#[ORM\Column(length: 14, nullable: true)]
private ?string $siret = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $email = null;
#[ORM\Column(length: 20, nullable: true)]
private ?string $phone = null;
#[ORM\Column(length: 500, nullable: true)]
private ?string $address = null;
#[ORM\Column(length: 10, nullable: true)]
private ?string $zipCode = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $city = null;
#[ORM\Column(length: 20, options: ['default' => 'active'])]
private string $state = self::STATE_ACTIVE;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
/** @var Collection<int, FacturePrestataire> */
#[ORM\OneToMany(targetEntity: FacturePrestataire::class, mappedBy: 'prestataire', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['year' => 'DESC', 'month' => 'DESC'])]
private Collection $factures;
public function __construct(string $raisonSociale)
{
$this->raisonSociale = $raisonSociale;
$this->createdAt = new \DateTimeImmutable();
$this->factures = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getRaisonSociale(): string
{
return $this->raisonSociale;
}
public function setRaisonSociale(string $raisonSociale): static
{
$this->raisonSociale = $raisonSociale;
return $this;
}
public function getSiret(): ?string
{
return $this->siret;
}
public function setSiret(?string $siret): static
{
$this->siret = $siret;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
$this->email = $email;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): static
{
$this->phone = $phone;
return $this;
}
public function getAddress(): ?string
{
return $this->address;
}
public function setAddress(?string $address): static
{
$this->address = $address;
return $this;
}
public function getZipCode(): ?string
{
return $this->zipCode;
}
public function setZipCode(?string $zipCode): static
{
$this->zipCode = $zipCode;
return $this;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): static
{
$this->city = $city;
return $this;
}
public function getState(): string
{
return $this->state;
}
public function setState(string $state): static
{
$this->state = $state;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
/** @return Collection<int, FacturePrestataire> */
public function getFactures(): Collection
{
return $this->factures;
}
public function addFacture(FacturePrestataire $facture): static
{
if (!$this->factures->contains($facture)) {
$this->factures->add($facture);
}
return $this;
}
public function getFullAddress(): string
{
return trim(($this->address ?? '').' '.($this->zipCode ?? '').' '.($this->city ?? ''));
}
/**
* Total HT de toutes les factures payees.
*/
public function getTotalPaidHt(): float
{
$total = 0.0;
foreach ($this->factures as $f) {
if ($f->isPaid()) {
$total += (float) $f->getMontantHt();
}
}
return $total;
}
}

View File

@@ -12,8 +12,8 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class SubdomainRedirectListener
{
private const SUBDOMAIN_ROUTES = [
'webmail.siteconseil.fr' => 'app_webmail_login',
'status.siteconseil.fr' => 'app_status',
'webmail.e-cosplay.fr' => 'app_webmail_login',
'status.e-cosplay.fr' => 'app_status',
];
public function __construct(

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\Prestataire;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Prestataire>
*/
class PrestataireRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Prestataire::class);
}
}

View File

@@ -50,6 +50,11 @@ class KeycloakAuthenticator extends OAuth2Authenticator implements Authenticatio
$lastName = $keycloakData['family_name'] ?? '';
$groups = $keycloakData['groups'] ?? [];
// Securite : verifier que l'email appartient au domaine @e-cosplay.fr
if ('' === $email || !str_contains($email, '@e-cosplay.fr')) {
throw new AuthenticationException('Acces refuse : votre adresse email n\'appartient pas au domaine @e-cosplay.fr');
}
// Chercher l'utilisateur par keycloakId ou email
$user = $this->userRepository->findOneBy(['keycloakId' => $keycloakId])
?? $this->userRepository->findOneBy(['email' => $email]);
@@ -85,7 +90,7 @@ class KeycloakAuthenticator extends OAuth2Authenticator implements Authenticatio
{
$session = $request->getSession();
if ($session instanceof FlashBagAwareSessionInterface) {
$session->getFlashBag()->add('error', 'Echec de la connexion SITECONSEIL : '.$exception->getMessage());
$session->getFlashBag()->add('error', 'Echec de la connexion E-Cosplay : '.$exception->getMessage());
}
return new RedirectResponse($this->router->generate('app_home'));
@@ -103,10 +108,18 @@ class KeycloakAuthenticator extends OAuth2Authenticator implements Authenticatio
*/
private function resolveRoles(array $groups): array
{
if (\in_array('siteconseil_admin', $groups, true)) {
if (\in_array('superadmin', $groups, true)) {
return ['ROLE_ROOT'];
}
return ['ROLE_EMPLOYE'];
if (\in_array('super_admin_asso', $groups, true)) {
return ['ROLE_ROOT'];
}
if (\in_array('gp_member', $groups, true)) {
return ['ROLE_EMPLOYE'];
}
return ['ROLE_USER'];
}
}

View File

@@ -25,9 +25,9 @@ class TwoFactorCodeMailer implements AuthCodeMailerInterface
]);
$email = (new Email())
->from('CRM SITECONSEIL <contact@siteconseil.fr>')
->from('CRM E-Cosplay <contact@e-cosplay.fr>')
->to($user->getEmailAuthRecipient())
->subject('CRM SITECONSEIL - Code de verification')
->subject('CRM E-Cosplay - Code de verification')
->html($html);
$this->mailer->send($email);

View File

@@ -0,0 +1,314 @@
<?php
namespace App\Service;
use App\Entity\ActionLog;
use App\Entity\Advert;
use App\Entity\Customer;
use App\Entity\Domain;
use App\Entity\DomainEmail;
use App\Entity\Website;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Service centralise pour les actions destructrices et critiques.
* Chaque action est loggee de facon exhaustive (ActionLog + Psr\Log).
*/
class ActionService
{
public function __construct(
private EntityManagerInterface $em,
private LoggerInterface $logger,
private MailerService $mailer,
) {
}
// ──────── Suspension client ────────
/**
* Suspend un client : desactive le compte + suspend tous ses sites + suspend tous ses emails.
*/
public function suspendCustomer(Customer $customer, string $reason = 'Impaye'): bool
{
$previousState = $customer->getState();
if (Customer::STATE_SUSPENDED === $previousState) {
$this->log(ActionLog::ACTION_SUSPEND_CUSTOMER, $customer, 'Client deja suspendu', 'warning', true);
return true;
}
$this->log(ActionLog::ACTION_SUSPEND_CUSTOMER, $customer,
'Debut suspension client '.$customer->getFullName().' ('.$customer->getEmail().') - Raison: '.$reason,
'critical', true, $previousState, Customer::STATE_SUSPENDED
);
try {
// 1. Suspend le compte client
$customer->setState(Customer::STATE_SUSPENDED);
$this->em->flush();
$this->log(ActionLog::ACTION_SUSPEND_CUSTOMER, $customer,
'Compte client passe en suspended', 'info', true, $previousState, Customer::STATE_SUSPENDED
);
// 2. Suspend tous les sites
$websites = $this->em->getRepository(Website::class)->findBy(['customer' => $customer]);
foreach ($websites as $website) {
$this->suspendWebsite($website, $customer, $reason);
}
// 3. Suspend tous les emails
$domains = $this->em->getRepository(Domain::class)->findBy(['customer' => $customer]);
foreach ($domains as $domain) {
$emails = $this->em->getRepository(DomainEmail::class)->findBy(['domain' => $domain]);
foreach ($emails as $email) {
$this->suspendDomainEmail($email, $customer, $reason);
}
}
$this->log(ActionLog::ACTION_SUSPEND_CUSTOMER, $customer,
'Suspension complete - Sites: '.\count($websites).' - Domaines: '.\count($domains),
'critical', true
);
return true;
} catch (\Throwable $e) {
$this->logError(ActionLog::ACTION_SUSPEND_CUSTOMER, $customer, 'Erreur suspension client', $e);
return false;
}
}
/**
* Retablit un client suspendu : reactive le compte + reactive les sites + reactive les emails.
*/
public function unsuspendCustomer(Customer $customer, string $reason = 'Paiement recu'): bool
{
$previousState = $customer->getState();
if (Customer::STATE_SUSPENDED !== $previousState) {
$this->log(ActionLog::ACTION_UNSUSPEND_CUSTOMER, $customer, 'Client non suspendu, rien a faire', 'warning', true);
return true;
}
$this->log(ActionLog::ACTION_UNSUSPEND_CUSTOMER, $customer,
'Debut retablissement client '.$customer->getFullName().' - Raison: '.$reason,
'info', true, $previousState, Customer::STATE_ACTIVE
);
try {
$customer->setState(Customer::STATE_ACTIVE);
$this->em->flush();
// Reactive les sites
$websites = $this->em->getRepository(Website::class)->findBy(['customer' => $customer, 'state' => Website::STATE_SUSPENDED]);
foreach ($websites as $website) {
$this->unsuspendWebsite($website, $customer, $reason);
}
// Reactive les emails
$domains = $this->em->getRepository(Domain::class)->findBy(['customer' => $customer]);
foreach ($domains as $domain) {
$emails = $this->em->getRepository(DomainEmail::class)->findBy(['domain' => $domain, 'state' => 'suspended']);
foreach ($emails as $email) {
$this->unsuspendDomainEmail($email, $customer, $reason);
}
}
$this->log(ActionLog::ACTION_UNSUSPEND_CUSTOMER, $customer,
'Retablissement complet', 'info', true
);
return true;
} catch (\Throwable $e) {
$this->logError(ActionLog::ACTION_UNSUSPEND_CUSTOMER, $customer, 'Erreur retablissement client', $e);
return false;
}
}
// ──────── Suspension site ────────
public function suspendWebsite(Website $website, Customer $customer, string $reason = 'Impaye'): void
{
$prev = $website->getState();
if (Website::STATE_SUSPENDED === $prev) {
return;
}
$website->setState(Website::STATE_SUSPENDED);
$this->em->flush();
$this->log(ActionLog::ACTION_SUSPEND_WEBSITE, $customer,
'Site '.$website->getName().' (UUID '.$website->getUuid().') suspendu - Raison: '.$reason,
'critical', true, $prev, Website::STATE_SUSPENDED,
$website->getId(), 'Website'
);
}
public function unsuspendWebsite(Website $website, Customer $customer, string $reason = 'Paiement recu'): void
{
$prev = $website->getState();
$website->setState(Website::STATE_OPEN);
$this->em->flush();
$this->log(ActionLog::ACTION_UNSUSPEND_WEBSITE, $customer,
'Site '.$website->getName().' retabli - Raison: '.$reason,
'info', true, $prev, Website::STATE_OPEN,
$website->getId(), 'Website'
);
}
// ──────── Suspension email ────────
public function suspendDomainEmail(DomainEmail $email, Customer $customer, string $reason = 'Impaye'): void
{
$prev = $email->getState();
if ('suspended' === $prev) {
return;
}
$email->setState('suspended');
$this->em->flush();
$this->log(ActionLog::ACTION_SUSPEND_DOMAIN_EMAIL, $customer,
'Email '.$email->getFullEmail().' suspendu - Raison: '.$reason,
'critical', true, $prev, 'suspended',
$email->getId(), 'DomainEmail'
);
}
public function unsuspendDomainEmail(DomainEmail $email, Customer $customer, string $reason = 'Paiement recu'): void
{
$prev = $email->getState();
$email->setState('active');
$this->em->flush();
$this->log(ActionLog::ACTION_UNSUSPEND_DOMAIN_EMAIL, $customer,
'Email '.$email->getFullEmail().' retabli - Raison: '.$reason,
'info', true, $prev, 'active',
$email->getId(), 'DomainEmail'
);
}
// ──────── Desactivation + suppression donnees ────────
/**
* Desactive definitivement un client (pre-suppression).
*/
public function disableCustomer(Customer $customer, string $reason = 'Resiliation pour impaye'): bool
{
$previousState = $customer->getState();
$this->log(ActionLog::ACTION_DISABLE_CUSTOMER, $customer,
'Desactivation definitive '.$customer->getFullName().' - Raison: '.$reason,
'critical', true, $previousState, Customer::STATE_DISABLED
);
try {
$customer->setState(Customer::STATE_DISABLED);
$this->em->flush();
return true;
} catch (\Throwable $e) {
$this->logError(ActionLog::ACTION_DISABLE_CUSTOMER, $customer, 'Erreur desactivation', $e);
return false;
}
}
/**
* Marque un client pour suppression (pending_delete, sera traite par le cron CleanPendingDeleteCommand).
*/
public function markForDeletion(Customer $customer, string $reason = 'Resiliation contrat + recouvrement'): bool
{
$previousState = $customer->getState();
$this->log(ActionLog::ACTION_DELETE_CUSTOMER_DATA, $customer,
'Marquage suppression client '.$customer->getFullName().' ('.$customer->getEmail().') - Raison: '.$reason.' - Toutes les donnees seront supprimees par le cron nocturne.',
'critical', true, $previousState, Customer::STATE_PENDING_DELETE,
);
try {
$customer->setState(Customer::STATE_PENDING_DELETE);
$this->em->flush();
$this->logger->critical('ActionService: client '.$customer->getEmail().' marque pour suppression - '.$reason);
return true;
} catch (\Throwable $e) {
$this->logError(ActionLog::ACTION_DELETE_CUSTOMER_DATA, $customer, 'Erreur marquage suppression', $e);
return false;
}
}
// ──────── Logging ────────
private function log(
string $action,
Customer $customer,
string $message,
string $severity = 'info',
bool $success = true,
?string $previousState = null,
?string $newState = null,
?int $entityId = null,
?string $entityType = null,
): void {
$log = new ActionLog($action, $message, $severity, $success);
$log->setCustomer($customer);
$log->setPreviousState($previousState);
$log->setNewState($newState);
if (null !== $entityId) {
$log->setEntityId($entityId);
$log->setEntityType($entityType);
}
$this->em->persist($log);
$this->em->flush();
$logMethod = match ($severity) {
'critical' => 'critical',
'danger' => 'error',
'warning' => 'warning',
default => 'info',
};
$this->logger->$logMethod('ActionService: '.$message, [
'action' => $action,
'customer_id' => $customer->getId(),
'customer_email' => $customer->getEmail(),
'previous_state' => $previousState,
'new_state' => $newState,
'entity_id' => $entityId,
'entity_type' => $entityType,
]);
}
private function logError(string $action, Customer $customer, string $message, \Throwable $e): void
{
$log = new ActionLog($action, $message, 'critical', false);
$log->setCustomer($customer);
$log->setErrorMessage($e->getMessage());
$log->setContext(json_encode([
'exception' => $e::class,
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => array_slice(explode("\n", $e->getTraceAsString()), 0, 5),
]));
$this->em->persist($log);
$this->em->flush();
$this->logger->critical('ActionService ERROR: '.$message.': '.$e->getMessage(), [
'action' => $action,
'customer_id' => $customer->getId(),
'exception' => $e,
]);
}
}

View File

@@ -13,9 +13,21 @@ class AdvertService
private OrderNumberService $orderNumberService,
private EntityManagerInterface $em,
#[Autowire('%env(APP_SECRET)%')] private string $hmacSecret,
#[Autowire(env: 'TVA_ENABLED')] private string $tvaEnabled = 'false',
#[Autowire(env: 'TVA_RATE')] private string $tvaRate = '0.20',
) {
}
public function isTvaEnabled(): bool
{
return 'true' === $this->tvaEnabled || '1' === $this->tvaEnabled;
}
public function getTvaRate(): float
{
return (float) $this->tvaRate;
}
public function create(?Devis $devis = null): Advert
{
$orderNumber = null !== $devis
@@ -38,4 +50,26 @@ class AdvertService
{
return $this->create($devis);
}
/**
* Calcule les totaux selon la config TVA.
*/
public function computeTotals(string $totalHt): array
{
$ht = (float) $totalHt;
if ($this->isTvaEnabled()) {
$tva = round($ht * $this->getTvaRate(), 2);
$ttc = round($ht + $tva, 2);
} else {
$tva = 0.0;
$ttc = $ht;
}
return [
'totalHt' => number_format($ht, 2, '.', ''),
'totalTva' => number_format($tva, 2, '.', ''),
'totalTtc' => number_format($ttc, 2, '.', ''),
];
}
}

View File

@@ -12,9 +12,21 @@ class DevisService
private OrderNumberService $orderNumberService,
private EntityManagerInterface $em,
#[Autowire('%env(APP_SECRET)%')] private string $hmacSecret,
#[Autowire(env: 'TVA_ENABLED')] private string $tvaEnabled = 'false',
#[Autowire(env: 'TVA_RATE')] private string $tvaRate = '0.20',
) {
}
public function isTvaEnabled(): bool
{
return 'true' === $this->tvaEnabled || '1' === $this->tvaEnabled;
}
public function getTvaRate(): float
{
return (float) $this->tvaRate;
}
public function create(): Devis
{
$orderNumber = $this->orderNumberService->generateAndUse();
@@ -25,4 +37,26 @@ class DevisService
return $devis;
}
/**
* Calcule les totaux du devis selon la config TVA.
*/
public function computeTotals(string $totalHt): array
{
$ht = (float) $totalHt;
if ($this->isTvaEnabled()) {
$tva = round($ht * $this->getTvaRate(), 2);
$ttc = round($ht + $tva, 2);
} else {
$tva = 0.0;
$ttc = $ht;
}
return [
'totalHt' => number_format($ht, 2, '.', ''),
'totalTva' => number_format($tva, 2, '.', ''),
'totalTtc' => number_format($ttc, 2, '.', ''),
];
}
}

View File

@@ -7,10 +7,10 @@ namespace App\Service;
*/
class DnsInfraHelper
{
public const DOMAINS = ['siteconseil.fr', 'esy-web.dev'];
public const DOMAINS = ['e-cosplay.fr', 'esy-web.dev'];
public const EXPECTED_MX = [
'siteconseil.fr' => 'mail.esy-web.dev',
'e-cosplay.fr' => 'mail.esy-web.dev',
'esy-web.dev' => 'mail.esy-web.dev',
];

View File

@@ -24,6 +24,11 @@ class DocuSealService
$this->api = new Api($apiKey, rtrim($baseUrl, '/').'/api');
}
public function getApi(): Api
{
return $this->api;
}
/**
* Envoie l'attestation a DocuSeal pour auto-signature en un seul appel.
*/
@@ -48,8 +53,8 @@ class DocuSealService
],
'submitters' => [
[
'email' => 'contact@siteconseil.fr',
'name' => 'SARL SITECONSEIL',
'email' => 'contact@e-cosplay.fr',
'name' => 'Association E-Cosplay',
'role' => 'First Party',
'completed' => true,
'send_email' => false,
@@ -192,6 +197,37 @@ class DocuSealService
}
}
/**
* Recupere les donnees completes d'un submitter.
*
* @return array<string, mixed>|null
*/
public function getSubmitterData(int $submitterId): ?array
{
try {
return $this->api->getSubmitter($submitterId);
} catch (\Throwable) {
return null;
}
}
/**
* Archive une submission dans DocuSeal (invalide tous les liens de signature).
*/
public function archiveSubmission(int $submissionId): bool
{
try {
$this->api->archiveSubmission($submissionId);
$this->logger->info('DocuSeal: submission '.$submissionId.' archivee');
return true;
} catch (\Throwable $e) {
$this->logger->warning('DocuSeal: echec archive submission '.$submissionId.': '.$e->getMessage());
return false;
}
}
/**
* Telecharge et sauvegarde via Vich le PDF signe et le certificat d'audit depuis DocuSeal.
*/
@@ -275,12 +311,72 @@ class DocuSealService
}
}
/**
* Envoie un PDF comptable a DocuSeal pour signature par un utilisateur.
* Retourne l'id du submitter pour suivi.
*/
public function sendComptaForSignature(
string $pdfContent,
string $documentName,
string $signerEmail,
string $signerName,
string $exportType,
string $periodFrom,
string $periodTo,
?string $completedRedirectUrl = null,
): ?int {
try {
$pdfBase64 = base64_encode($pdfContent);
$submitter = [
'email' => $signerEmail,
'name' => $signerName,
'role' => 'First Party',
'send_email' => false,
'metadata' => [
'doc_type' => 'comptabilite',
'export_type' => $exportType,
'period_from' => $periodFrom,
'period_to' => $periodTo,
],
];
if (null !== $completedRedirectUrl) {
$submitter['completed_redirect_url'] = $completedRedirectUrl;
}
$result = $this->api->createSubmissionFromPdf([
'name' => $documentName,
'send_email' => false,
'flatten' => true,
'documents' => [
[
'name' => str_replace(' ', '_', $documentName).'.pdf',
'file' => 'data:application/pdf;base64,'.$pdfBase64,
],
],
'submitters' => [$submitter],
]);
$this->logger->info('DocuSeal compta: reponse API', ['result' => $result]);
return $result['submitters'][0]['id'] ?? ($result[0]['id'] ?? null);
} catch (\Throwable $e) {
$this->logger->error('DocuSeal compta: exception API: '.$e->getMessage(), [
'exception' => $e,
'document' => $documentName,
]);
return null;
}
}
private function getLogoBase64(): string
{
$logoPath = $this->projectDir.'/public/logo_facture.png';
$logoPath = $this->projectDir.'/public/logo.jpg';
if (!file_exists($logoPath)) {
return 'SARL SITECONSEIL';
return 'Association E-Cosplay';
}
return 'data:image/jpeg;base64,'.base64_encode(file_get_contents($logoPath));

View File

@@ -347,7 +347,7 @@ class EsyMailService
// ──── Vérification DNS ──────────────────────────────
/**
* Vérifie la config DNS Esy-Mail (réception) pour un domaine.
* Vérifie la config DNS E-Mail (réception) pour un domaine.
* MX → mail hostname, SPF includes, DKIM, DMARC.
*
* @return array{ok: bool, mx: bool, spf: bool, dkim: bool, dmarc: bool, details: array<string, string>}
@@ -402,7 +402,7 @@ class EsyMailService
}
/**
* Vérifie la config DNS Esy-Mailer (envoi AWS SES) pour un domaine.
* Vérifie la config DNS E-Mailer (envoi AWS SES) pour un domaine.
* SES domaine vérifié, DKIM SES, SPF include:amazonses.com, MAIL FROM.
*
* @return array{ok: bool, ses_verified: bool, ses_dkim: bool, spf_ses: bool, mail_from: bool, details: array<string, string>}

View File

@@ -3,8 +3,11 @@
namespace App\Service;
use App\Entity\Advert;
use App\Entity\AdvertLine;
use App\Entity\Facture;
use App\Entity\FactureLine;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class FactureService
@@ -12,10 +15,23 @@ class FactureService
public function __construct(
private OrderNumberService $orderNumberService,
private EntityManagerInterface $em,
private LoggerInterface $logger,
#[Autowire('%env(APP_SECRET)%')] private string $hmacSecret,
#[Autowire(env: 'TVA_ENABLED')] private string $tvaEnabled = 'false',
#[Autowire(env: 'TVA_RATE')] private string $tvaRate = '0.20',
) {
}
public function isTvaEnabled(): bool
{
return 'true' === $this->tvaEnabled || '1' === $this->tvaEnabled;
}
public function getTvaRate(): float
{
return (float) $this->tvaRate;
}
public function create(?Advert $advert = null): Facture
{
if (null !== $advert) {
@@ -37,6 +53,12 @@ class FactureService
$facture = new Facture($advert->getOrderNumber(), $this->hmacSecret);
$facture->setAdvert($advert);
$facture->setCustomer($advert->getCustomer());
// Copie des totaux
$facture->setTotalHt($advert->getTotalHt());
$facture->setTotalTva($advert->getTotalTva());
$facture->setTotalTtc($advert->getTotalTtc());
if ($existingCount > 0) {
$facture->setSplitIndex($existingCount + 1);
@@ -47,9 +69,87 @@ class FactureService
}
}
// Copie des lignes depuis l'avis
foreach ($advert->getLines() as $advertLine) {
$line = new FactureLine(
$facture,
$advertLine->getTitle(),
$advertLine->getPriceHt(),
$advertLine->getPos(),
);
if (null !== $advertLine->getDescription()) {
$line->setDescription($advertLine->getDescription());
}
if (null !== $advertLine->getType()) {
$line->setType($advertLine->getType());
}
if (null !== $advertLine->getServiceId()) {
$line->setServiceId($advertLine->getServiceId());
}
$this->em->persist($line);
$facture->addLine($line);
}
$this->em->persist($facture);
$this->em->flush();
$this->logger->info('FactureService: facture '.$facture->getInvoiceNumber().' creee depuis advert '.$advert->getOrderNumber()->getNumOrder());
return $facture;
}
/**
* Cree une facture depuis un avis dont le paiement est complet.
* Marque la facture comme payee avec la date et la methode.
*/
public function createPaidFactureFromAdvert(Advert $advert, string $amount, string $methodLabel): ?Facture
{
$advertTotal = (float) $advert->getTotalTtc();
$paidAmount = (float) $amount;
// Ne generer la facture que si le montant paye == montant de l'avis
if (abs($advertTotal - $paidAmount) > 0.01) {
$this->logger->warning('FactureService: montant paye ('.$amount.') != montant avis ('.$advert->getTotalTtc().') - facture non generee', [
'advert' => $advert->getOrderNumber()->getNumOrder(),
]);
return null;
}
$facture = $this->createFromAdvert($advert);
// Marquer comme payee
$facture->setIsPaid(true);
$facture->setPaidAt(new \DateTimeImmutable());
$facture->setPaidMethod($methodLabel);
$facture->setState(Facture::STATE_PAID);
$this->em->flush();
$this->logger->info('FactureService: facture '.$facture->getInvoiceNumber().' marquee payee ('.$methodLabel.', '.$amount.' EUR)');
return $facture;
}
/**
* Calcule les totaux selon la config TVA.
*/
public function computeTotals(string $totalHt): array
{
$ht = (float) $totalHt;
if ($this->isTvaEnabled()) {
$tva = round($ht * $this->getTvaRate(), 2);
$ttc = round($ht + $tva, 2);
} else {
$tva = 0.0;
$ttc = $ht;
}
return [
'totalHt' => number_format($ht, 2, '.', ''),
'totalTva' => number_format($tva, 2, '.', ''),
'totalTtc' => number_format($ttc, 2, '.', ''),
];
}
}

View File

@@ -0,0 +1,282 @@
<?php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Wrapper API Google Search Console, Indexing API et URL Inspection API.
* Auth via Service Account (JWT OAuth2 RS256).
*
* @codeCoverageIgnore
*/
class GoogleSearchService
{
private const SEARCH_CONSOLE_API = 'https://www.googleapis.com/webmasters/v3';
private const INDEXING_API = 'https://indexing.googleapis.com/v3';
private const INSPECTION_API = 'https://searchconsole.googleapis.com/v1';
private const TOKEN_URI = 'https://oauth2.googleapis.com/token';
private const SCOPES = [
'https://www.googleapis.com/auth/webmasters',
'https://www.googleapis.com/auth/indexing',
];
private ?string $accessToken = null;
private ?int $tokenExpiresAt = null;
public function __construct(
private HttpClientInterface $httpClient,
private LoggerInterface $logger,
#[Autowire(env: 'GOOGLE_SEARCH_CONSOLE_KEY')] private string $serviceAccountJson = '',
) {
}
public function isAvailable(): bool
{
return '' !== $this->serviceAccountJson;
}
/**
* Ajoute un site dans Google Search Console.
*/
public function addSite(string $url): bool
{
return null !== $this->request('PUT', self::SEARCH_CONSOLE_API.'/sites/'.urlencode($url));
}
/**
* Verifie la propriete d'un site.
*/
public function verifySite(string $url): bool
{
return null !== $this->request('POST', 'https://www.googleapis.com/siteVerification/v1/webResource?verificationMethod=FILE', [
'site' => ['type' => 'SITE', 'identifier' => $url],
]);
}
/**
* Supprime un site de Search Console.
*/
public function removeSite(string $url): bool
{
return null !== $this->request('DELETE', self::SEARCH_CONSOLE_API.'/sites/'.urlencode($url));
}
/**
* Soumet un sitemap.
*/
public function submitSitemap(string $siteUrl, string $sitemapUrl): bool
{
return null !== $this->request('PUT', self::SEARCH_CONSOLE_API.'/sites/'.urlencode($siteUrl).'/sitemaps/'.urlencode($sitemapUrl));
}
/**
* Liste les sitemaps d'un site.
*
* @return list<array<string, mixed>>
*/
public function listSitemaps(string $siteUrl): array
{
$result = $this->request('GET', self::SEARCH_CONSOLE_API.'/sites/'.urlencode($siteUrl).'/sitemaps');
return $result['sitemap'] ?? [];
}
/**
* Verifie le statut d'indexation d'une URL via URL Inspection API.
*
* @return array<string, mixed>|null
*/
public function getIndexingStatus(string $url): ?array
{
$parsed = parse_url($url);
$siteUrl = ($parsed['scheme'] ?? 'https').'://'.($parsed['host'] ?? '').'/';
return $this->request('POST', self::INSPECTION_API.'/urlInspection/index:inspect', [
'inspectionUrl' => $url,
'siteUrl' => $siteUrl,
]);
}
/**
* Demande l'indexation d'une URL via Indexing API.
*/
public function submitUrlForIndexing(string $url): bool
{
return null !== $this->request('POST', self::INDEXING_API.'/urlNotifications:publish', [
'url' => $url,
'type' => 'URL_UPDATED',
]);
}
/**
* Recupere les donnees de performance (Search Analytics).
*
* @return array{clicks: int, impressions: int, ctr: float, position: float, rows: list<array<string, mixed>>}|null
*/
public function getPerformanceData(string $siteUrl, string $startDate, string $endDate): ?array
{
$result = $this->request('POST', self::SEARCH_CONSOLE_API.'/sites/'.urlencode($siteUrl).'/searchAnalytics/query', [
'startDate' => $startDate,
'endDate' => $endDate,
'dimensions' => ['query', 'page'],
'rowLimit' => 100,
]);
if (null === $result) {
return null;
}
$totals = ['clicks' => 0, 'impressions' => 0, 'ctr' => 0.0, 'position' => 0.0];
$rows = $result['rows'] ?? [];
foreach ($rows as $row) {
$totals['clicks'] += $row['clicks'] ?? 0;
$totals['impressions'] += $row['impressions'] ?? 0;
}
if (\count($rows) > 0) {
$totals['ctr'] = $totals['impressions'] > 0 ? $totals['clicks'] / $totals['impressions'] : 0.0;
$totals['position'] = array_sum(array_column($rows, 'position')) / \count($rows);
}
return [
'clicks' => $totals['clicks'],
'impressions' => $totals['impressions'],
'ctr' => round($totals['ctr'], 4),
'position' => round($totals['position'], 1),
'rows' => $rows,
];
}
/**
* Recupere les erreurs de crawl via URL Inspection (le endpoint crawlErrors est deprecie).
*
* @return list<array<string, mixed>>
*/
public function getCrawlErrors(string $siteUrl): array
{
// L'ancien endpoint urlCrawlErrorsCounts est deprecie.
// On utilise l'URL Inspection API sur les pages du sitemap comme fallback.
$this->logger->info('GoogleSearchService::getCrawlErrors: utilise URL Inspection API (endpoint crawlErrors deprecie)');
return [];
}
// ──────── Auth OAuth2 JWT ────────
/**
* @return array<string, mixed>|null
*/
private function request(string $method, string $url, ?array $body = null): ?array
{
if (!$this->isAvailable()) {
$this->logger->warning('GoogleSearchService: service account non configure');
return null;
}
try {
$token = $this->getAccessToken();
$options = [
'headers' => [
'Authorization' => 'Bearer '.$token,
'Content-Type' => 'application/json',
],
];
if (null !== $body) {
$options['json'] = $body;
}
$response = $this->httpClient->request($method, $url, $options);
$statusCode = $response->getStatusCode();
if ($statusCode >= 400) {
$this->logger->error('GoogleSearchService: HTTP '.$statusCode.' '.$method.' '.$url, [
'body' => $response->getContent(false),
]);
return null;
}
if (204 === $statusCode || '' === $response->getContent(false)) {
return [];
}
return $response->toArray();
} catch (\Throwable $e) {
$this->logger->error('GoogleSearchService: '.$e->getMessage(), [
'method' => $method,
'url' => $url,
]);
return null;
}
}
private function getAccessToken(): string
{
if (null !== $this->accessToken && null !== $this->tokenExpiresAt && time() < $this->tokenExpiresAt) {
return $this->accessToken;
}
$serviceAccount = json_decode($this->serviceAccountJson, true);
if (!\is_array($serviceAccount)) {
throw new \RuntimeException('GOOGLE_SEARCH_CONSOLE_KEY JSON invalide');
}
$jwt = $this->createJwt($serviceAccount, self::SCOPES);
$response = $this->httpClient->request('POST', self::TOKEN_URI, [
'body' => [
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
],
]);
$data = $response->toArray();
$this->accessToken = $data['access_token'];
$this->tokenExpiresAt = time() + ($data['expires_in'] ?? 3600) - 60;
return $this->accessToken;
}
/**
* @param array<string, mixed> $serviceAccount
* @param list<string> $scopes
*/
private function createJwt(array $serviceAccount, array $scopes): string
{
$now = time();
$header = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$claims = $this->base64UrlEncode(json_encode([
'iss' => $serviceAccount['client_email'],
'scope' => implode(' ', $scopes),
'aud' => self::TOKEN_URI,
'iat' => $now,
'exp' => $now + 3600,
]));
$payload = $header.'.'.$claims;
$privateKey = $serviceAccount['private_key'];
$signature = '';
if (!openssl_sign($payload, $signature, $privateKey, \OPENSSL_ALGO_SHA256)) {
throw new \RuntimeException('Erreur signature JWT (openssl_sign)');
}
return $payload.'.'.$this->base64UrlEncode($signature);
}
private function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}

View File

@@ -11,23 +11,18 @@ class KeycloakAdminService
private const PATH_GROUPS = '/groups';
private const AUTH_BEARER = 'Bearer ';
/** Groupes requis pour le CRM SITECONSEIL */
/** Groupes requis pour le CRM E-Cosplay */
private const REQUIRED_GROUPS = [
'siteconseil_admin',
'siteconseil_member',
'esy-web',
'esy-mail',
'esy-mailer',
'esy-analytics',
'esy-monitor',
'esy-defender',
'esy-translate',
'esy-signature',
'esy-creator',
'esy-aide',
'esy-meet',
'esy-tchat',
'esy-ndd',
'superadmin',
'super_admin_asso',
'gp_asso',
'gp_contest',
'gp_mail',
'gp_mailling',
'gp_member',
'gp_ndd',
'gp_sign',
'gp_ticket',
];
private ?string $accessToken = null;

View File

@@ -31,7 +31,7 @@ class MailerService
public function getAdminFrom(): string
{
return 'SARL SITECONSEIL <'.$this->adminEmail.'>';
return 'Association E-Cosplay <'.$this->adminEmail.'>';
}
public function send(Email $email): void
@@ -92,7 +92,7 @@ class MailerService
}
$messageId = bin2hex(random_bytes(16));
$email->getHeaders()->addIdHeader('Message-ID', $messageId.'@siteconseil.fr');
$email->getHeaders()->addIdHeader('Message-ID', $messageId.'@e-cosplay.fr');
$trackingUrl = $this->urlGenerator->generate('app_email_track', [
'messageId' => $messageId,
@@ -103,7 +103,7 @@ class MailerService
], UrlGeneratorInterface::ABSOLUTE_URL);
$html = $email->getHtmlBody();
$html = str_replace('https://crm.siteconseil.fr/logo_facture.png', $trackingUrl, $html);
$html = str_replace('https://crm.e-cosplay.fr/logo.jpg', $trackingUrl, $html);
$html = str_replace('__VIEW_URL__', $viewUrl, $html);
$dnsReportUrl = $this->urlGenerator->generate('app_dns_report', [
@@ -122,10 +122,10 @@ class MailerService
$this->em->persist($tracking);
$this->em->flush();
// Ajout automatique du fichier VCF (fiche contact SITECONSEIL)
// Ajout automatique du fichier VCF (fiche contact E-Cosplay)
$vcfPath = $this->generateVcf();
if (null !== $vcfPath) {
$email->attachFromPath($vcfPath, 'SARL-SITECONSEIL.vcf', 'text/vcard');
$email->attachFromPath($vcfPath, 'Association-E-Cosplay.vcf', 'text/vcard');
}
if ($canUnsubscribe) {
@@ -208,24 +208,24 @@ class MailerService
}
/**
* Genere un fichier VCF (vCard 3.0) pour la fiche contact SARL SITECONSEIL.
* Genere un fichier VCF (vCard 3.0) pour la fiche contact Association E-Cosplay.
*/
private function generateVcf(): ?string
{
$vcf = implode("\r\n", [
'BEGIN:VCARD',
'VERSION:3.0',
'N:SITECONSEIL;SARL;;;',
'FN:SARL SITECONSEIL',
'ORG:SARL SITECONSEIL',
'TEL;TYPE=WORK,VOICE:+33323056243',
'EMAIL;TYPE=INTERNET,PREF:contact@siteconseil.fr',
'EMAIL;TYPE=INTERNET:s.com@siteconseil.fr',
'ADR;TYPE=WORK:;;27 rue Le Serurier;Saint-Quentin;;02100;France',
'URL:https://www.siteconseil.fr',
'URL:https://crm.siteconseil.fr',
'NOTE:SIREN 943121517 - SIRET 418 664 058 00025 - APE 6201Z',
'CATEGORIES:Prestataire,IT,Web',
'N:E-Cosplay;Association;;;',
'FN:Association E-Cosplay',
'ORG:Association E-Cosplay',
'TEL;TYPE=WORK,VOICE:+33679348802',
'EMAIL;TYPE=INTERNET,PREF:contact@e-cosplay.fr',
'EMAIL;TYPE=INTERNET:contact@e-cosplay.fr',
'ADR;TYPE=WORK:;;42 rue de Saint-Quentin;Beautor;;02800;France',
'URL:https://www.e-cosplay.fr',
'URL:https://crm.e-cosplay.fr',
'NOTE:SIREN 943121517 - SIRET 943 121 517 00011 - APE 9329Z',
'CATEGORIES:Association,Cosplay,Evenementiel',
'REV:'.date('Ymd\THis\Z'),
'END:VCARD',
]);
@@ -263,7 +263,7 @@ class MailerService
$email->getHeaders()->addTextHeader(
'List-Unsubscribe',
sprintf('<%s>, <mailto:unsubscribe@siteconseil.fr?subject=unsubscribe-%s>', $unsubscribeUrl, urlencode($to))
sprintf('<%s>, <mailto:unsubscribe@e-cosplay.fr?subject=unsubscribe-%s>', $unsubscribeUrl, urlencode($to))
);
$email->getHeaders()->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click');
}

View File

@@ -4,6 +4,7 @@ namespace App\Service;
use App\Entity\Advert;
use App\Entity\Customer;
use App\Entity\Facture;
use App\Entity\CustomerContact;
use App\Entity\Devis;
use App\Entity\Domain;
@@ -300,6 +301,65 @@ class MeilisearchService
}
}
public function indexFacture(Facture $facture): void
{
try {
$this->client->index('customer_facture')->addDocuments([$this->serializeFacture($facture)]);
} catch (\Throwable $e) {
$this->logger->error('Meilisearch: Failed to index facture '.$facture->getId().': '.$e->getMessage());
}
}
public function removeFacture(int $factureId): void
{
try {
$this->client->index('customer_facture')->deleteDocument($factureId);
} catch (\Throwable $e) {
$this->logger->error('Meilisearch: Failed to remove facture '.$factureId.': '.$e->getMessage());
}
}
/** @return list<array<string, mixed>> */
public function searchFactures(string $query, int $limit = 20, ?int $customerId = null): array
{
try {
$options = ['limit' => $limit];
if (null !== $customerId) {
$options['filter'] = 'customerId = '.$customerId;
}
return $this->client->index('customer_facture')->search($query, $options)->getHits();
} catch (\Throwable $e) {
$this->logger->error('Meilisearch: search factures error: '.$e->getMessage());
return [];
}
}
public function purgeAllIndexes(): void
{
$indexes = ['customer', 'reseller', 'price_auto', 'customer_contact', 'customer_ndd', 'customer_website', 'customer_devis', 'customer_advert', 'customer_facture'];
foreach ($indexes as $index) {
try {
$this->client->index($index)->deleteAllDocuments();
} catch (\Throwable $e) {
$this->logger->warning('Meilisearch: purge index '.$index.' failed: '.$e->getMessage());
}
}
}
public function getIndexCount(string $indexName): int
{
try {
$stats = $this->client->index($indexName)->stats();
return $stats['numberOfDocuments'] ?? 0;
} catch (\Throwable) {
return 0;
}
}
public function setupIndexes(): void
{
try {
@@ -397,6 +457,18 @@ class MeilisearchService
$this->client->index('customer_advert')->updateFilterableAttributes([
'customerId', 'state',
]);
try {
$this->client->createIndex('customer_facture', ['primaryKey' => 'id']);
} catch (\Throwable $e) {
$this->logger->warning('Meilisearch: setupIndexes (customer_facture) - '.$e->getMessage());
}
$this->client->index('customer_facture')->updateSearchableAttributes([
'invoiceNumber', 'customerName', 'customerEmail', 'state',
]);
$this->client->index('customer_facture')->updateFilterableAttributes([
'customerId', 'state', 'isPaid',
]);
}
/**
@@ -537,6 +609,26 @@ class MeilisearchService
];
}
/** @return array<string, mixed> */
private function serializeFacture(Facture $facture): array
{
$customer = $facture->getCustomer();
return [
'id' => $facture->getId(),
'invoiceNumber' => $facture->getInvoiceNumber(),
'state' => $facture->getState(),
'totalHt' => $facture->getTotalHt(),
'totalTtc' => $facture->getTotalTtc(),
'isPaid' => $facture->isPaid(),
'paidMethod' => $facture->getPaidMethod(),
'customerId' => $customer?->getId(),
'customerName' => $customer?->getFullName(),
'customerEmail' => $customer?->getEmail(),
'createdAt' => $facture->getCreatedAt()->format('Y-m-d'),
];
}
/** @return array<string, mixed> */
private function serializeAdvert(Advert $advert): array
{

View File

@@ -19,12 +19,17 @@ class AdvertPdf extends Fpdi
private array $items = [];
private string $qrBase64 = '';
private bool $skipHeaderFooter = false;
private int $lastAdvertPage = 0;
private ?\Twig\Environment $twig = null;
public function __construct(
private readonly KernelInterface $kernel,
private readonly Advert $advert,
?UrlGeneratorInterface $urlGenerator = null,
?\Twig\Environment $twig = null,
) {
$this->twig = $twig;
parent::__construct();
// Generation QR code vers la page de paiement
@@ -58,10 +63,14 @@ class AdvertPdf extends Fpdi
public function Header(): void
{
if ($this->skipHeaderFooter && $this->PageNo() > $this->lastAdvertPage) {
return;
}
$this->SetFont('Arial', '', 10);
$logo = $this->kernel->getProjectDir().'/public/logo_facture.png';
$logo = $this->kernel->getProjectDir().'/public/logo.jpg';
if (file_exists($logo)) {
$this->Image($logo, 65, 5, 80);
$this->Image($logo, 10, 8, 53);
}
$formatter = new \IntlDateFormatter(
@@ -73,7 +82,7 @@ class AdvertPdf extends Fpdi
);
$numText = $this->enc('AVIS DE PAIEMENT N° '.$this->advert->getOrderNumber()->getNumOrder());
$dateText = $this->enc('Saint-Quentin, '.$formatter->format($this->advert->getCreatedAt()));
$dateText = $this->enc('Emis a Beautor, le '.$formatter->format($this->advert->getCreatedAt()));
$this->Text(15, 80, $numText);
$this->Text(15, 85, $dateText);
@@ -156,6 +165,62 @@ class AdvertPdf extends Fpdi
$this->displaySummary();
$this->displayQrCode();
$this->appendCgv();
}
private function appendCgv(): void
{
if (null === $this->twig) {
return;
}
try {
$html = $this->twig->render('pdf/cgv.html.twig');
$dompdf = new \Dompdf\Dompdf();
$dompdf->loadHtml($html);
$dompdf->setPaper('A4');
$dompdf->render();
$tmpCgv = tempnam(sys_get_temp_dir(), 'cgv_').'.pdf';
file_put_contents($tmpCgv, $dompdf->output());
$this->lastAdvertPage = $this->PageNo();
$this->skipHeaderFooter = true;
$pageCount = $this->setSourceFile($tmpCgv);
for ($i = 1; $i <= $pageCount; ++$i) {
$tpl = $this->importPage($i);
$size = $this->getTemplateSize($tpl);
$this->AddPage($size['orientation'] ?? 'P', [$size['width'], $size['height']]);
$this->useTemplate($tpl);
}
@unlink($tmpCgv);
} catch (\Throwable) {
}
}
private function appendRib(): void
{
$ribPath = $this->kernel->getProjectDir().'/public/rib.pdf';
if (!file_exists($ribPath)) {
return;
}
try {
$this->lastAdvertPage = $this->PageNo();
$this->skipHeaderFooter = true;
$pageCount = $this->setSourceFile($ribPath);
for ($i = 1; $i <= $pageCount; ++$i) {
$tpl = $this->importPage($i);
$size = $this->getTemplateSize($tpl);
$this->AddPage($size['orientation'] ?? 'P', [$size['width'], $size['height']]);
$this->useTemplate($tpl);
}
} catch (\Throwable) {
}
}
private function displayQrCode(): void
@@ -165,7 +230,7 @@ class AdvertPdf extends Fpdi
}
$this->SetAutoPageBreak(false);
$y = $this->GetPageHeight() - 55;
$y = $this->GetPageHeight() - 80;
// QR code en bas a gauche
$tmpQr = tempnam(sys_get_temp_dir(), 'qr_').'.png';
@@ -175,52 +240,65 @@ class AdvertPdf extends Fpdi
$this->Image($tmpQr, 15, $y, 30, 30);
@unlink($tmpQr);
// Texte a droite du QR
$this->SetXY(50, $y + 5);
// Texte sous le QR code
$this->SetXY(15, $y + 32);
$this->SetFont('Arial', 'B', 9);
$this->SetTextColor(0, 0, 0);
$this->Cell(100, 5, $this->enc('Scannez pour payer'), 0, 1, 'L');
$this->SetX(50);
$this->SetFont('Arial', '', 8);
$this->Cell(50, 5, $this->enc('Scannez pour payer'), 0, 1, 'L');
$this->SetX(15);
$this->SetFont('Arial', '', 7);
$this->SetTextColor(100, 100, 100);
$this->Cell(100, 5, $this->enc('Flashez ce QR code pour acceder aux options de paiement.'), 0, 1, 'L');
$this->Cell(60, 4, $this->enc('Flashez ce QR code pour acceder'), 0, 1, 'L');
$this->SetX(15);
$this->Cell(60, 4, $this->enc('aux options de paiement.'), 0, 1, 'L');
}
private function displaySummary(): void
{
$totalHt = array_sum(array_column($this->items, 'priceHt'));
$totalTva = $totalHt * 0.20;
$totalTtc = $totalHt + $totalTva;
$totalHt = (float) $this->advert->getTotalHt();
$totalTva = (float) $this->advert->getTotalTva();
$totalTtc = (float) $this->advert->getTotalTtc();
$tvaEnabled = $totalTva > 0.01;
$this->SetY(-60);
$this->SetFont('Arial', '', 12);
$this->Cell(100, 10, $this->enc('Total HT :'), 0, 0, 'R');
$this->Cell(40, 10, number_format($totalHt, 2, ',', ' ').' '.EURO, 0, 1, 'R');
$this->Cell(135, 10, $this->enc('TVA (20%) :'), 0, 0, 'R');
$this->Cell(35, 10, number_format($totalTva, 2, ',', ' ').' '.EURO, 0, 1, 'R');
$this->SetY($tvaEnabled ? -60 : -50);
$this->SetFont('Arial', 'B', 12);
$this->Cell(135, 10, $this->enc('Total TTC :'), 0, 0, 'R');
$this->Cell(35, 10, number_format($totalTtc, 2, ',', ' ').' '.EURO, 0, 1, 'R');
$this->SetX(105);
$this->Cell(40, 10, $this->enc('Total HT :'), 0, 0, 'R');
$this->Cell(40, 10, number_format($totalHt, 2, ',', ' ').' '.EURO, 0, 1, 'R');
if ($tvaEnabled) {
$this->SetFont('Arial', '', 12);
$this->SetX(105);
$this->Cell(40, 10, $this->enc('TVA (20%) :'), 0, 0, 'R');
$this->Cell(40, 10, number_format($totalTva, 2, ',', ' ').' '.EURO, 0, 1, 'R');
$this->SetFont('Arial', 'B', 12);
$this->SetX(105);
$this->Cell(40, 10, $this->enc('Total TTC :'), 0, 0, 'R');
$this->Cell(40, 10, number_format($totalTtc, 2, ',', ' ').' '.EURO, 0, 1, 'R');
} else {
$this->SetFont('Arial', '', 8);
$this->SetX(105);
$this->Cell(80, 5, $this->enc('TVA non applicable - art. 293 B du CGI'), 0, 1, 'R');
}
}
public function Footer(): void
{
$this->SetY(-32);
$this->Ln(10);
$this->SetFont('Arial', 'B', 8);
$this->SetTextColor(253, 140, 4);
if ($this->skipHeaderFooter && $this->PageNo() > $this->lastAdvertPage) {
return;
}
$this->SetY(-28);
$this->SetDrawColor(253, 140, 4);
$this->Cell(190, 5, $this->enc('Partenaire de vos projects de puis 1997'), 0, 1, 'C');
$this->Line(15, $this->GetY(), 195, $this->GetY());
$this->Ln(4);
$this->SetFont('Arial', '', 8);
$this->SetTextColor(0, 0, 0);
$this->Ln(2);
$this->Cell(190, 4, $this->enc('27, rue le Sérurier - 02100 SAINT-QUENTIN - Tél: 03 23 05 62 43'), 0, 1, 'C');
$this->Cell(190, 4, $this->enc('e-mail : s.com@siteconseil.fr - www.siteconseil.fr'), 0, 1, 'C');
$this->Cell(190, 4, $this->enc('S.A.R.L aux captial de 71400 ').EURO.' - '.$this->enc('N°SIRET 418 664 058 00025 - N° TVA FR 05 418 664 058 - CODE APE 6201 Z - R.C. St-Quentin 418 664 058'), 0, 1, 'C');
$this->Cell(190, 4, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tél: 06 79 34 88 02'), 0, 1, 'C');
$this->Cell(190, 4, $this->enc('e-mail : contact@e-cosplay.fr - www.e-cosplay.fr'), 0, 1, 'C');
$this->Cell(190, 4, $this->enc('Association E-Cosplay - N°SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C');
$this->SetFont('Arial', 'I', 7);
$this->SetTextColor(150, 150, 150);

View File

@@ -0,0 +1,409 @@
<?php
namespace App\Service\Pdf;
use setasign\Fpdi\Fpdi;
use Symfony\Component\HttpKernel\KernelInterface;
if (!\defined('EURO')) {
\define('EURO', \chr(128));
}
class ComptaPdf extends Fpdi
{
private string $documentTitle;
private string $periodFrom;
private string $periodTo;
/** @var list<array<string, string>> */
private array $rows = [];
/** @var list<string> */
private array $columns = [];
/** @var array<string, int> */
private array $columnWidths = [];
public function __construct(
private readonly KernelInterface $kernel,
string $documentTitle,
string $periodFrom,
string $periodTo,
) {
parent::__construct();
$this->documentTitle = $documentTitle;
$this->periodFrom = $periodFrom;
$this->periodTo = $periodTo;
$this->SetTitle($this->enc($documentTitle));
$this->SetAuthor($this->enc('Association E-Cosplay'));
$this->SetCreator('CRM E-Cosplay');
}
/**
* @param list<array<string, string>> $rows
*/
public function setData(array $rows): void
{
$this->rows = $rows;
if (!empty($rows)) {
$this->columns = array_keys($rows[0]);
$this->columnWidths = $this->computeColumnWidths();
}
}
public function generate(): void
{
$this->AliasNbPages();
$this->AddPage('L');
$this->writeContextBlock();
$this->writeDataTable();
$this->writeSummary();
$this->writeSignatureBlock();
}
// ---------------------------------------------------------------
// Header / Footer
// ---------------------------------------------------------------
public function Header(): void
{
$logo = $this->kernel->getProjectDir().'/public/logo.jpg';
if (file_exists($logo)) {
$this->Image($logo, 10, 8, 40);
}
// Titre du document
$this->SetFont('Arial', 'B', 16);
$this->SetXY(60, 10);
$this->Cell(0, 8, $this->enc(mb_strtoupper($this->documentTitle)), 0, 1, 'L');
// Periode
$this->SetFont('Arial', '', 10);
$this->SetXY(60, 18);
$this->Cell(0, 5, $this->enc('Periode : du '.$this->periodFrom.' au '.$this->periodTo), 0, 1, 'L');
// Date de generation
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::FULL,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$this->SetXY(60, 23);
$this->SetFont('Arial', '', 9);
$this->SetTextColor(100, 100, 100);
$this->Cell(0, 5, $this->enc('Genere le '.$formatter->format(new \DateTime())), 0, 1, 'L');
$this->SetTextColor(0, 0, 0);
$this->Ln(5);
}
public function Footer(): void
{
$this->SetY(-20);
$this->SetDrawColor(253, 140, 4);
$this->Line(15, $this->GetY(), $this->GetPageWidth() - 15, $this->GetY());
$this->Ln(3);
$this->SetFont('Arial', '', 7);
$this->SetTextColor(0, 0, 0);
$this->Cell(0, 3, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tel: 06 79 34 88 02 - contact@e-cosplay.fr - www.e-cosplay.fr'), 0, 1, 'C');
$this->Cell(0, 3, $this->enc('Association E-Cosplay - N SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C');
$this->SetFont('Arial', 'I', 7);
$this->SetTextColor(150, 150, 150);
$this->Cell(0, 3, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C');
}
// ---------------------------------------------------------------
// Bloc legal / contextuel
// ---------------------------------------------------------------
private function writeContextBlock(): void
{
$this->SetY(35);
$labelW = 55;
$dataW = 120;
// Ligne superieure
$this->SetDrawColor(200, 200, 200);
$this->Cell(0, 0.5, '', 'T', 1, 'L');
$this->Ln(2);
$this->SetFont('Arial', 'B', 10);
$this->Cell(0, 5, $this->enc('INFORMATIONS LEGALES ET CONTEXTUELLES'), 0, 1, 'C');
$this->Ln(1);
$this->SetFont('Arial', '', 9);
// Association / RNA
$this->Cell($labelW, 5, $this->enc('Association :'), 0, 0, 'L');
$this->SetFont('Arial', 'B', 9);
$this->Cell($dataW, 5, $this->enc('E-Cosplay Association loi 1901 - RNA N W022006988'), 0, 1, 'L');
$this->SetFont('Arial', '', 9);
// Siege social
$this->Cell($labelW, 5, $this->enc('Siege social :'), 0, 0, 'L');
$this->Cell($dataW, 5, $this->enc('42 rue de Saint-Quentin 02800 Beautor'), 0, 1, 'L');
// SIRET
$this->Cell($labelW, 5, $this->enc('SIRET :'), 0, 0, 'L');
$this->Cell($dataW, 5, $this->enc('943 121 517 00011'), 0, 1, 'L');
// Code APE
$this->Cell($labelW, 5, $this->enc('Code APE :'), 0, 0, 'L');
$this->Cell($dataW, 5, $this->enc('9329Z'), 0, 1, 'L');
// Document
$this->Cell($labelW, 5, $this->enc('Document :'), 0, 0, 'L');
$this->SetFont('Arial', 'B', 9);
$this->Cell($dataW, 5, $this->enc($this->documentTitle), 0, 1, 'L');
$this->SetFont('Arial', '', 9);
// Periode
$this->Cell($labelW, 5, $this->enc('Periode comptable :'), 0, 0, 'L');
$this->Cell($dataW, 5, $this->enc('Du '.$this->periodFrom.' au '.$this->periodTo), 0, 1, 'L');
// Ligne inferieure
$this->Ln(2);
$this->Cell(0, 0.5, '', 'T', 1, 'L');
$this->Ln(3);
}
// ---------------------------------------------------------------
// Tableau de donnees
// ---------------------------------------------------------------
private function writeDataTable(): void
{
if (empty($this->rows)) {
$this->SetFont('Arial', '', 11);
$this->Cell(0, 10, $this->enc('Aucune donnee sur cette periode.'), 0, 1, 'C');
return;
}
// En-tete du tableau
$this->SetFont('Arial', 'B', 7);
$this->SetFillColor(35, 35, 35);
$this->SetTextColor(255, 255, 255);
foreach ($this->columns as $col) {
$w = $this->columnWidths[$col] ?? 25;
$this->Cell($w, 6, $this->enc($col), 1, 0, 'C', true);
}
$this->Ln();
$this->SetTextColor(0, 0, 0);
// Donnees
$this->SetFont('Arial', '', 7);
$fill = false;
foreach ($this->rows as $row) {
// Verifier si on a besoin d'une nouvelle page
if ($this->GetY() + 5 > $this->GetPageHeight() - 30) {
$this->AddPage('L');
$this->SetY(35);
// Re-dessiner l'en-tete du tableau
$this->SetFont('Arial', 'B', 7);
$this->SetFillColor(35, 35, 35);
$this->SetTextColor(255, 255, 255);
foreach ($this->columns as $col) {
$w = $this->columnWidths[$col] ?? 25;
$this->Cell($w, 6, $this->enc($col), 1, 0, 'C', true);
}
$this->Ln();
$this->SetTextColor(0, 0, 0);
$this->SetFont('Arial', '', 7);
$fill = false;
}
$this->SetFillColor(245, 245, 240);
foreach ($this->columns as $col) {
$w = $this->columnWidths[$col] ?? 25;
$value = $row[$col] ?? '';
$align = $this->isNumericColumn($col) ? 'R' : 'L';
$this->Cell($w, 5, $this->enc($value), 'B', 0, $align, $fill);
}
$this->Ln();
$fill = !$fill;
}
$this->Ln(3);
}
// ---------------------------------------------------------------
// Resume / totaux
// ---------------------------------------------------------------
private function writeSummary(): void
{
$this->SetFont('Arial', 'B', 9);
$this->Cell(0, 6, $this->enc('Total : '.\count($this->rows).' ecriture(s)'), 0, 1, 'L');
// Si colonnes Debit/Credit, afficher les totaux
$totalDebit = 0.0;
$totalCredit = 0.0;
$hasDebitCredit = false;
foreach ($this->rows as $row) {
if (isset($row['Debit'])) {
$totalDebit += (float) $row['Debit'];
$hasDebitCredit = true;
}
if (isset($row['Credit'])) {
$totalCredit += (float) $row['Credit'];
}
// Grand livre / balance
if (isset($row['MontantHT'])) {
$totalDebit += (float) $row['MontantHT'];
}
}
if ($hasDebitCredit) {
$this->SetFont('Arial', '', 9);
$this->Cell(60, 5, $this->enc('Total Debit : '.number_format($totalDebit, 2, ',', ' ')).' '.EURO, 0, 0, 'L');
$this->Cell(60, 5, $this->enc('Total Credit : '.number_format($totalCredit, 2, ',', ' ')).' '.EURO, 0, 1, 'L');
}
$this->Ln(5);
}
// ---------------------------------------------------------------
// Bloc signature (champ DocuSeal)
// ---------------------------------------------------------------
private function writeSignatureBlock(): void
{
// S'assurer qu'on a assez de place
if ($this->GetY() + 45 > $this->GetPageHeight() - 25) {
$this->AddPage('L');
$this->SetY(35);
}
$this->SetDrawColor(200, 200, 200);
$this->Cell(0, 0.5, '', 'T', 1, 'L');
$this->Ln(3);
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::LONG,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$this->SetFont('Arial', '', 9);
$this->Cell(0, 5, $this->enc('Fait a Beautor, le '.$formatter->format(new \DateTime())), 0, 1, 'L');
$this->Ln(2);
$this->SetFont('Arial', 'B', 9);
$this->Cell(0, 5, $this->enc('Signature du responsable :'), 0, 1, 'L');
$this->Ln(1);
// Champ signature DocuSeal (meme format que DevisPdf)
$this->SetAutoPageBreak(false);
$this->SetFont('Arial', '', 10);
$this->SetTextColor(0, 0, 0);
$this->Cell(60, 20, '{{Sign;type=signature;role=First Party}}', 0, 1, 'L');
}
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
/**
* @return array<string, int>
*/
private function computeColumnWidths(): array
{
if (empty($this->columns)) {
return [];
}
$pageWidth = 277; // A4 landscape - margins
$widths = [];
// Colonnes connues avec taille fixe
$known = [
'JournalCode' => 15,
'JournalLib' => 28,
'EcritureNum' => 22,
'EcritureDate' => 20,
'CompteNum' => 25,
'CompteLib' => 35,
'CompAuxNum' => 22,
'CompAuxLib' => 30,
'PieceRef' => 22,
'PieceDate' => 20,
'EcritureLib' => 45,
'Debit' => 18,
'Credit' => 18,
'Lettrage' => 14,
'DateLettrage' => 20,
'ValidDate' => 20,
'MontantDevise' => 18,
'Idevise' => 12,
'EcrtureLet' => 14,
'DateLet' => 18,
'Montantdevise' => 18,
'Solde' => 18,
'CodeJournal' => 15,
'Statut' => 16,
'MethodePaiement' => 24,
'DatePaiement' => 20,
'CodeComptable' => 25,
'Client' => 40,
'NumFacture' => 25,
'DateFacture' => 20,
'MontantHT' => 20,
'MontantTVA' => 20,
'MontantTTC' => 20,
'JoursRetard' => 16,
'Tranche' => 22,
'DateReglement' => 22,
'CompteBanque' => 20,
];
$usedWidth = 0;
$unknownCols = [];
foreach ($this->columns as $col) {
if (isset($known[$col])) {
$widths[$col] = $known[$col];
$usedWidth += $known[$col];
} else {
$unknownCols[] = $col;
}
}
// Distribuer l'espace restant aux colonnes inconnues
if (!empty($unknownCols)) {
$remaining = max($pageWidth - $usedWidth, \count($unknownCols) * 15);
$each = (int) floor($remaining / \count($unknownCols));
foreach ($unknownCols as $col) {
$widths[$col] = $each;
}
}
return $widths;
}
private function isNumericColumn(string $col): bool
{
return \in_array($col, [
'Debit', 'Credit', 'Solde',
'MontantHT', 'MontantTVA', 'MontantTTC',
'MontantDevise', 'Montantdevise',
'JoursRetard',
], true);
}
private function enc(string $text): string
{
return mb_convert_encoding($text, 'Windows-1252', 'UTF-8');
}
}

View File

@@ -20,11 +20,14 @@ class DevisPdf extends Fpdi
private array $items = [];
private int $lastDevisPage = 0;
private ?\Twig\Environment $twig = null;
public function __construct(
private readonly KernelInterface $kernel,
private readonly Devis $devis,
?\Twig\Environment $twig = null,
) {
$this->twig = $twig;
parent::__construct();
$items = [];
@@ -49,9 +52,9 @@ class DevisPdf extends Fpdi
}
$this->SetFont('Arial', '', 10);
$logo = $this->kernel->getProjectDir().'/public/logo_facture.png';
$logo = $this->kernel->getProjectDir().'/public/logo.jpg';
if (file_exists($logo)) {
$this->Image($logo, 65, 5, 80);
$this->Image($logo, 10, 8, 53);
}
$formatter = new \IntlDateFormatter(
@@ -63,7 +66,7 @@ class DevisPdf extends Fpdi
);
$numDevisText = $this->enc('DEVIS N° '.$this->devis->getOrderNumber()->getNumOrder());
$dateText = $this->enc('Saint-Quentin, '.$formatter->format($this->devis->getCreatedAt()));
$dateText = $this->enc('Emis a Beautor, le '.$formatter->format($this->devis->getCreatedAt()));
$this->Text(15, 80, $numDevisText);
$this->Text(15, 85, $dateText);
@@ -148,24 +151,40 @@ class DevisPdf extends Fpdi
}
$this->displaySummary();
$this->displaySign();
$this->appendCgv();
}
/**
* Importe les pages de public/cgv.pdf a la suite du devis, sans appliquer le Header/Footer devis.
* Genere les CGV depuis le template Twig via Dompdf puis les importe via FPDI.
*/
private function displaySign(): void
{
$this->SetAutoPageBreak(false);
$this->SetXY(15, $this->GetPageHeight() - 45);
$this->SetFont('Arial', '', 10);
$this->SetTextColor(0, 0, 0);
$this->Cell(30, 10, '{{Sign;type=signature;role=First Party}}', 0, 0, 'L');
}
private function appendCgv(): void
{
$cgvPath = $this->kernel->getProjectDir().'/public/cgv.pdf';
if (!file_exists($cgvPath)) {
if (null === $this->twig) {
return;
}
try {
$pageCount = $this->setSourceFile($cgvPath);
$html = $this->twig->render('pdf/cgv.html.twig');
$dompdf = new \Dompdf\Dompdf();
$dompdf->loadHtml($html);
$dompdf->setPaper('A4');
$dompdf->render();
$tmpCgv = tempnam(sys_get_temp_dir(), 'cgv_').'.pdf';
file_put_contents($tmpCgv, $dompdf->output());
// Marque la derniere page devis : toutes les pages ajoutees apres auront le Header/Footer desactive
$this->lastDevisPage = $this->PageNo();
$pageCount = $this->setSourceFile($tmpCgv);
for ($i = 1; $i <= $pageCount; ++$i) {
$tpl = $this->importPage($i);
@@ -173,42 +192,50 @@ class DevisPdf extends Fpdi
$this->AddPage($size['orientation'] ?? 'P', [$size['width'], $size['height']]);
$this->useTemplate($tpl);
// Sur la DERNIERE page CGV : ajoute un champ signature DocuSeal independant
// en bas a gauche, sans declencher de saut de page automatique
// Sur la DERNIERE page CGV : champ signature DocuSeal
if ($i === $pageCount) {
$this->SetAutoPageBreak(false);
$this->SetXY(15, $this->GetPageHeight() - 100);
$this->SetXY(15, $this->GetPageHeight() - 35);
$this->SetFont('Arial', '', 10);
$this->SetTextColor(0, 0, 0);
$this->Cell(60, 20, '{{SignCGV;type=signature;role=First Party}}', 0, 0, 'L');
}
}
@unlink($tmpCgv);
} catch (\Throwable) {
// Silencieux : si CGV corrompu on garde juste le devis
}
}
private function displaySummary(): void
{
$totalHt = array_sum(array_column($this->items, 'priceHt'));
$totalTva = $totalHt * 0.20;
$totalTtc = $totalHt + $totalTva;
$totalHt = (float) $this->devis->getTotalHt();
$totalTva = (float) $this->devis->getTotalTva();
$totalTtc = (float) $this->devis->getTotalTtc();
$tvaEnabled = $totalTva > 0.01;
$this->SetY(-60);
// Zone signature (placeholder DocuSeal) en bas a gauche de la derniere page devis
$this->Cell(30, 10, '{{Sign;type=signature;role=First Party}}', 0, 0, 'L');
$this->SetFont('Arial', '', 12);
$this->Cell(100, 10, $this->enc('Total HT :'), 0, 0, 'R');
$this->Cell(40, 10, number_format($totalHt, 2, ',', ' ').' '.EURO, 0, 1, 'R');
$this->Cell(135, 10, $this->enc('TVA (20%) :'), 0, 0, 'R');
$this->Cell(35, 10, number_format($totalTva, 2, ',', ' ').' '.EURO, 0, 1, 'R');
$this->SetY($tvaEnabled ? -60 : -50);
$this->SetFont('Arial', 'B', 12);
$this->Cell(135, 10, $this->enc('Total TTC :'), 0, 0, 'R');
$this->Cell(35, 10, number_format($totalTtc, 2, ',', ' ').' '.EURO, 0, 1, 'R');
$this->SetX(105);
$this->Cell(40, 10, $this->enc('Total HT :'), 0, 0, 'R');
$this->Cell(40, 10, number_format($totalHt, 2, ',', ' ').' '.EURO, 0, 1, 'R');
if ($tvaEnabled) {
$this->SetFont('Arial', '', 12);
$this->SetX(105);
$this->Cell(40, 10, $this->enc('TVA (20%) :'), 0, 0, 'R');
$this->Cell(40, 10, number_format($totalTva, 2, ',', ' ').' '.EURO, 0, 1, 'R');
$this->SetFont('Arial', 'B', 12);
$this->SetX(105);
$this->Cell(40, 10, $this->enc('Total TTC :'), 0, 0, 'R');
$this->Cell(40, 10, number_format($totalTtc, 2, ',', ' ').' '.EURO, 0, 1, 'R');
} else {
$this->SetFont('Arial', '', 8);
$this->SetX(105);
$this->Cell(80, 5, $this->enc('TVA non applicable - art. 293 B du CGI'), 0, 1, 'R');
}
}
public function Footer(): void
@@ -218,19 +245,15 @@ class DevisPdf extends Fpdi
return;
}
$this->SetY(-32);
$this->Ln(10);
$this->SetFont('Arial', 'B', 8);
$this->SetTextColor(253, 140, 4);
$this->SetY(-28);
$this->SetDrawColor(253, 140, 4);
$this->Cell(190, 5, $this->enc('Partenaire de vos projects de puis 1997'), 0, 1, 'C');
$this->Line(15, $this->GetY(), 195, $this->GetY());
$this->Ln(4);
$this->SetFont('Arial', '', 8);
$this->SetTextColor(0, 0, 0);
$this->Ln(2);
$this->Cell(190, 4, $this->enc('27, rue le Sérurier - 02100 SAINT-QUENTIN - Tél: 03 23 05 62 43'), 0, 1, 'C');
$this->Cell(190, 4, $this->enc('e-mail : s.com@siteconseil.fr - www.siteconseil.fr'), 0, 1, 'C');
$this->Cell(190, 4, $this->enc('S.A.R.L aux captial de 71400 ').EURO.' - '.$this->enc('N°SIRET 418 664 058 00025 - N° TVA FR 05 418 664 058 - CODE APE 6201 Z - R.C. St-Quentin 418 664 058'), 0, 1, 'C');
$this->Cell(190, 4, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tél: 06 79 34 88 02'), 0, 1, 'C');
$this->Cell(190, 4, $this->enc('e-mail : contact@e-cosplay.fr - www.e-cosplay.fr'), 0, 1, 'C');
$this->Cell(190, 4, $this->enc('Association E-Cosplay - N°SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C');
// Numero de page avec alias {nb}
$this->SetFont('Arial', 'I', 7);

View File

@@ -0,0 +1,338 @@
<?php
namespace App\Service\Pdf;
use App\Entity\Facture;
use Dompdf\Dompdf;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Writer\PngWriter;
use setasign\Fpdi\Fpdi;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
if (!\defined('EURO')) {
\define('EURO', \chr(128));
}
class FacturePdf extends Fpdi
{
/** @var array<int, array{title: string, content: string, priceHt: float}> */
private array $items = [];
private string $qrBase64 = '';
private bool $skipHeaderFooter = false;
private int $lastFacturePage = 0;
private ?\Twig\Environment $twig = null;
public function __construct(
private readonly KernelInterface $kernel,
private readonly Facture $facture,
?UrlGeneratorInterface $urlGenerator = null,
?\Twig\Environment $twig = null,
) {
$this->twig = $twig;
parent::__construct();
if (null !== $urlGenerator) {
$verifyUrl = $urlGenerator->generate('app_facture_verify', [
'id' => $facture->getId(),
'hmac' => $facture->getHmac(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$builder = new Builder(
writer: new PngWriter(),
data: $verifyUrl,
size: 200,
margin: 10,
);
$this->qrBase64 = 'data:image/png;base64,'.base64_encode($builder->build()->getString());
}
$items = [];
foreach ($this->facture->getLines() as $line) {
$items[$line->getPos()] = [
'title' => $line->getTitle(),
'content' => $line->getDescription() ?? '',
'priceHt' => (float) $line->getPriceHt(),
];
}
ksort($items);
$this->items = $items;
$this->SetTitle($this->enc('Facture N° '.$this->facture->getInvoiceNumber()));
}
public function Header(): void
{
if ($this->skipHeaderFooter && $this->PageNo() > $this->lastFacturePage) {
return;
}
$this->SetFont('Arial', '', 10);
$logo = $this->kernel->getProjectDir().'/public/logo.jpg';
if (file_exists($logo)) {
$this->Image($logo, 10, 8, 53);
}
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::FULL,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$numText = $this->enc('FACTURE N° '.$this->facture->getInvoiceNumber());
$dateText = $this->enc('Emis a Beautor, le '.$formatter->format($this->facture->getCreatedAt()));
$this->Text(15, 80, $numText);
$this->Text(15, 85, $dateText);
// Mention payee
if ($this->facture->isPaid()) {
$this->SetFont('Arial', 'B', 10);
$this->SetTextColor(22, 163, 74);
$paidText = $this->enc('PAYEE le '.$this->facture->getPaidAt()?->format('d/m/Y').' par '.$this->facture->getPaidMethod());
$this->Text(15, 90, $paidText);
$this->SetTextColor(0, 0, 0);
}
$this->SetFont('Arial', 'B', 12);
$y = 60;
$customer = $this->facture->getCustomer();
if (null !== $customer) {
$name = $customer->getRaisonSociale() ?: $customer->getFullName();
$this->Text(110, $y, $this->enc($name));
if ($address = $customer->getAddress()) {
$y += 5;
$this->Text(110, $y, $this->enc($address));
}
if ($address2 = $customer->getAddress2()) {
$y += 5;
$this->Text(110, $y, $this->enc($address2));
}
$y += 5;
$cityLine = ($customer->getZipCode() ?? '').' '.($customer->getCity() ?? '');
$this->Text(110, $y, $this->enc(trim($cityLine)));
}
$this->body();
}
private function body(): void
{
$this->SetFont('Arial', 'B', 10);
$this->SetXY(145, 100);
$this->Cell(40, 5, $this->enc('PRIX HT'), 0, 0, 'C');
$this->Line(145, 110, 145, 220);
$this->Line(185, 110, 185, 220);
$this->Line(0, 100, 5, 100);
$this->Line(0, 200, 5, 200);
}
public function generate(): void
{
$this->AliasNbPages();
$this->AddPage();
$this->SetFont('Arial', '', 12);
$startY = 110;
$this->SetY($startY);
$contentBottomLimit = 220;
foreach ($this->items as $item) {
if ($this->GetY() + 30 > $contentBottomLimit) {
$this->AddPage();
$this->body();
$this->SetY($startY);
}
$currentY = $this->GetY();
$this->SetX(20);
$this->SetFont('Arial', 'B', 11);
$this->Cell(95, 10, $this->enc($item['title']), 0, 0);
$this->SetFont('Arial', 'B', 11);
$this->SetXY(142, $currentY);
$this->Cell(39, 8, number_format($item['priceHt'], 2, ',', ' ').' '.EURO, 0, 1, 'R');
$this->SetFont('Arial', '', 11);
$this->SetX(30);
if ('' !== $item['content']) {
$this->MultiCell(90, 5, $this->enc($item['content']), 0, 'L');
}
$this->Ln(5);
}
$this->displaySummary();
$this->displayQrCode();
$this->appendCgv();
}
/**
* Genere les CGV depuis le template Twig via Dompdf puis les importe via FPDI.
*/
private function appendCgv(): void
{
if (null === $this->twig) {
return;
}
try {
// Rendre le HTML des CGV via un template PDF dedie
$html = $this->twig->render('pdf/cgv.html.twig');
// Generer un PDF temporaire via Dompdf
$dompdf = new Dompdf();
$dompdf->loadHtml($html);
$dompdf->setPaper('A4');
$dompdf->render();
$tmpCgv = tempnam(sys_get_temp_dir(), 'cgv_').'.pdf';
file_put_contents($tmpCgv, $dompdf->output());
// Importer les pages dans le PDF FPDI
$this->lastFacturePage = $this->PageNo();
$this->skipHeaderFooter = true;
$pageCount = $this->setSourceFile($tmpCgv);
for ($i = 1; $i <= $pageCount; ++$i) {
$tpl = $this->importPage($i);
$size = $this->getTemplateSize($tpl);
$this->AddPage($size['orientation'] ?? 'P', [$size['width'], $size['height']]);
$this->useTemplate($tpl);
}
@unlink($tmpCgv);
} catch (\Throwable) {
}
}
/**
* Importe les pages de public/rib.pdf apres les CGV.
*/
private function appendRib(): void
{
$ribPath = $this->kernel->getProjectDir().'/public/rib.pdf';
if (!file_exists($ribPath)) {
return;
}
try {
$pageCount = $this->setSourceFile($ribPath);
for ($i = 1; $i <= $pageCount; ++$i) {
$tpl = $this->importPage($i);
$size = $this->getTemplateSize($tpl);
$this->AddPage($size['orientation'] ?? 'P', [$size['width'], $size['height']]);
$this->useTemplate($tpl);
}
} catch (\Throwable) {
}
}
private function displayHmac(): void
{
$this->Ln(6);
$this->SetFont('Arial', '', 6);
$this->SetTextColor(150, 150, 150);
$this->Cell(190, 4, $this->enc('Signature HMAC-SHA256 : '.$this->facture->getHmac()), 0, 1, 'L');
$this->SetTextColor(0, 0, 0);
}
private function displayQrCode(): void
{
if ('' === $this->qrBase64) {
return;
}
$this->SetAutoPageBreak(false);
$y = $this->GetPageHeight() - 95;
$tmpQr = tempnam(sys_get_temp_dir(), 'qr_').'.png';
$pngData = base64_decode(str_replace('data:image/png;base64,', '', $this->qrBase64));
file_put_contents($tmpQr, $pngData);
$this->Image($tmpQr, 15, $y, 25, 25);
@unlink($tmpQr);
$this->SetXY(15, $y + 27);
$this->SetFont('Arial', 'B', 8);
$this->SetTextColor(0, 0, 0);
$this->Cell(50, 4, $this->enc('Verifier cette facture'), 0, 1, 'L');
$this->SetX(15);
$this->SetFont('Arial', '', 7);
$this->SetTextColor(100, 100, 100);
$this->Cell(60, 3, $this->enc('Scannez pour verifier l\'authenticite'), 0, 1, 'L');
$this->SetX(15);
$this->Cell(60, 3, $this->enc('de ce document.'), 0, 1, 'L');
}
private function displaySummary(): void
{
$totalHt = (float) $this->facture->getTotalHt();
$totalTva = (float) $this->facture->getTotalTva();
$totalTtc = (float) $this->facture->getTotalTtc();
$tvaEnabled = $totalTva > 0.01;
$this->SetY($tvaEnabled ? -60 : -50);
$this->SetFont('Arial', 'B', 12);
$this->SetX(105);
$this->Cell(40, 10, $this->enc('Total HT :'), 0, 0, 'R');
$this->Cell(40, 10, number_format($totalHt, 2, ',', ' ').' '.EURO, 0, 1, 'R');
if ($tvaEnabled) {
$this->SetFont('Arial', '', 12);
$this->SetX(105);
$this->Cell(40, 10, $this->enc('TVA (20%) :'), 0, 0, 'R');
$this->Cell(40, 10, number_format($totalTva, 2, ',', ' ').' '.EURO, 0, 1, 'R');
$this->SetFont('Arial', 'B', 12);
$this->SetX(105);
$this->Cell(40, 10, $this->enc('Total TTC :'), 0, 0, 'R');
$this->Cell(40, 10, number_format($totalTtc, 2, ',', ' ').' '.EURO, 0, 1, 'R');
} else {
$this->SetFont('Arial', '', 8);
$this->SetX(105);
$this->Cell(80, 5, $this->enc('TVA non applicable - art. 293 B du CGI'), 0, 1, 'R');
}
}
public function Footer(): void
{
if ($this->skipHeaderFooter && $this->PageNo() > $this->lastFacturePage) {
return;
}
$this->SetY(-28);
$this->SetDrawColor(253, 140, 4);
$this->Line(15, $this->GetY(), 195, $this->GetY());
$this->Ln(4);
$this->SetFont('Arial', '', 8);
$this->SetTextColor(0, 0, 0);
$this->Cell(190, 4, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tél: 06 79 34 88 02'), 0, 1, 'C');
$this->Cell(190, 4, $this->enc('e-mail : contact@e-cosplay.fr - www.e-cosplay.fr'), 0, 1, 'C');
$this->Cell(190, 4, $this->enc('Association E-Cosplay - N°SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C');
$this->SetFont('Arial', 'I', 7);
$this->SetTextColor(150, 150, 150);
$this->Cell(190, 4, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C');
}
private function enc(string $text): string
{
return mb_convert_encoding($text, 'Windows-1252', 'UTF-8');
}
}

View File

@@ -0,0 +1,355 @@
<?php
namespace App\Service\Pdf;
use setasign\Fpdi\Fpdi;
use Symfony\Component\HttpKernel\KernelInterface;
if (!\defined('EURO')) {
\define('EURO', \chr(128));
}
class RapportFinancierPdf extends Fpdi
{
private string $periodFrom;
private string $periodTo;
/** @var array<string, float> */
private array $recettes = [];
/** @var array<string, float> */
private array $depenses = [];
private float $totalRecettes = 0.0;
private float $totalDepenses = 0.0;
public function __construct(
private readonly KernelInterface $kernel,
string $periodFrom,
string $periodTo,
) {
parent::__construct();
$this->periodFrom = $periodFrom;
$this->periodTo = $periodTo;
$this->SetTitle($this->enc('Rapport financier - '.$periodFrom.' au '.$periodTo));
$this->SetAuthor($this->enc('Association E-Cosplay'));
$this->SetCreator('CRM E-Cosplay');
}
/**
* @param array<string, float> $recettes Libelle => montant
* @param array<string, float> $depenses Libelle => montant
*/
public function setData(array $recettes, array $depenses): void
{
$this->recettes = $recettes;
$this->depenses = $depenses;
$this->totalRecettes = array_sum($recettes);
$this->totalDepenses = array_sum($depenses);
}
public function generate(): void
{
$this->AliasNbPages();
$this->AddPage();
$this->writeContextBlock();
$this->writeRecettes();
$this->writeDepenses();
$this->writeBilan();
$this->writeSignatureBlock();
}
// ---------------------------------------------------------------
// Header / Footer
// ---------------------------------------------------------------
public function Header(): void
{
$logo = $this->kernel->getProjectDir().'/public/logo.jpg';
if (file_exists($logo)) {
$this->Image($logo, 10, 8, 45);
}
$this->SetFont('Arial', 'B', 18);
$this->SetXY(60, 10);
$this->Cell(0, 8, $this->enc('RAPPORT FINANCIER'), 0, 1, 'L');
$this->SetFont('Arial', '', 11);
$this->SetXY(60, 19);
$this->Cell(0, 5, $this->enc('Periode : du '.$this->periodFrom.' au '.$this->periodTo), 0, 1, 'L');
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::FULL,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$this->SetFont('Arial', '', 9);
$this->SetTextColor(120, 120, 120);
$this->SetXY(60, 25);
$this->Cell(0, 5, $this->enc('Edite le '.$formatter->format(new \DateTime())), 0, 1, 'L');
$this->SetTextColor(0, 0, 0);
// Mention document public
$this->SetFont('Arial', 'I', 8);
$this->SetTextColor(100, 100, 100);
$this->SetXY(10, 35);
$this->Cell(0, 4, $this->enc('Document public - Association loi 1901 - Les montants sont presentes de maniere synthetique.'), 0, 1, 'C');
$this->SetTextColor(0, 0, 0);
$this->Ln(5);
}
public function Footer(): void
{
$this->SetY(-22);
$this->SetDrawColor(253, 140, 4);
$this->Line(15, $this->GetY(), 195, $this->GetY());
$this->Ln(3);
$this->SetFont('Arial', '', 8);
$this->SetTextColor(0, 0, 0);
$this->Cell(190, 4, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tel: 06 79 34 88 02 - contact@e-cosplay.fr'), 0, 1, 'C');
$this->Cell(190, 4, $this->enc('Association E-Cosplay - N SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C');
$this->SetFont('Arial', 'I', 7);
$this->SetTextColor(150, 150, 150);
$this->Cell(190, 4, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C');
}
// ---------------------------------------------------------------
// Bloc legal
// ---------------------------------------------------------------
private function writeContextBlock(): void
{
$this->SetY(45);
$this->SetDrawColor(200, 200, 200);
$this->Cell(0, 0.5, '', 'T', 1, 'L');
$this->Ln(2);
$this->SetFont('Arial', 'B', 11);
$this->Cell(0, 6, $this->enc('INFORMATIONS LEGALES'), 0, 1, 'C');
$this->Ln(1);
$labelW = 55;
$this->SetFont('Arial', '', 9);
$this->Cell($labelW, 5, $this->enc('Association :'), 0, 0, 'L');
$this->SetFont('Arial', 'B', 9);
$this->Cell(0, 5, $this->enc('E-Cosplay - Association loi 1901 - RNA W022006988'), 0, 1, 'L');
$this->SetFont('Arial', '', 9);
$this->Cell($labelW, 5, $this->enc('Siege social :'), 0, 0, 'L');
$this->Cell(0, 5, $this->enc('42 rue de Saint-Quentin, 02800 Beautor'), 0, 1, 'L');
$this->Cell($labelW, 5, $this->enc('SIRET :'), 0, 0, 'L');
$this->Cell(0, 5, $this->enc('943 121 517 00011'), 0, 1, 'L');
$this->Cell($labelW, 5, $this->enc('Activite :'), 0, 0, 'L');
$this->Cell(0, 5, $this->enc('Services numeriques (hebergement, noms de domaine, messagerie)'), 0, 1, 'L');
$this->Ln(2);
$this->Cell(0, 0.5, '', 'T', 1, 'L');
$this->Ln(5);
}
// ---------------------------------------------------------------
// Recettes (montant entrant)
// ---------------------------------------------------------------
private function writeRecettes(): void
{
$this->SetFont('Arial', 'B', 12);
$this->SetTextColor(22, 163, 74);
$this->Cell(0, 7, $this->enc('RECETTES (MONTANTS ENTRANTS)'), 0, 1, 'L');
$this->SetTextColor(0, 0, 0);
$this->Ln(2);
// En-tete
$this->SetFont('Arial', 'B', 9);
$this->SetFillColor(35, 35, 35);
$this->SetTextColor(255, 255, 255);
$this->Cell(120, 7, $this->enc(' Service'), 1, 0, 'L', true);
$this->Cell(50, 7, $this->enc('Montant HT'), 1, 1, 'R', true);
$this->SetTextColor(0, 0, 0);
// Lignes
$this->SetFont('Arial', '', 10);
$fill = false;
foreach ($this->recettes as $label => $montant) {
$this->SetFillColor(245, 245, 240);
$this->Cell(120, 7, $this->enc(' '.$label), 'B', 0, 'L', $fill);
$this->Cell(50, 7, number_format($montant, 2, ',', ' ').' '.EURO, 'B', 1, 'R', $fill);
$fill = !$fill;
}
// Total
$this->SetFont('Arial', 'B', 11);
$this->SetFillColor(22, 163, 74);
$this->SetTextColor(255, 255, 255);
$this->Cell(120, 8, $this->enc(' TOTAL RECETTES'), 0, 0, 'L', true);
$this->Cell(50, 8, number_format($this->totalRecettes, 2, ',', ' ').' '.EURO, 0, 1, 'R', true);
$this->SetTextColor(0, 0, 0);
$this->Ln(8);
}
// ---------------------------------------------------------------
// Depenses (montant sortant)
// ---------------------------------------------------------------
private function writeDepenses(): void
{
$this->SetFont('Arial', 'B', 12);
$this->SetTextColor(220, 38, 38);
$this->Cell(0, 7, $this->enc('DEPENSES (MONTANTS SORTANTS)'), 0, 1, 'L');
$this->SetTextColor(0, 0, 0);
$this->Ln(2);
// En-tete
$this->SetFont('Arial', 'B', 9);
$this->SetFillColor(35, 35, 35);
$this->SetTextColor(255, 255, 255);
$this->Cell(120, 7, $this->enc(' Poste de depense'), 1, 0, 'L', true);
$this->Cell(50, 7, $this->enc('Montant'), 1, 1, 'R', true);
$this->SetTextColor(0, 0, 0);
// Lignes
$this->SetFont('Arial', '', 10);
$fill = false;
foreach ($this->depenses as $label => $montant) {
$this->SetFillColor(245, 245, 240);
$this->Cell(120, 7, $this->enc(' '.$label), 'B', 0, 'L', $fill);
$this->Cell(50, 7, number_format($montant, 2, ',', ' ').' '.EURO, 'B', 1, 'R', $fill);
$fill = !$fill;
}
// Total
$this->SetFont('Arial', 'B', 11);
$this->SetFillColor(220, 38, 38);
$this->SetTextColor(255, 255, 255);
$this->Cell(120, 8, $this->enc(' TOTAL DEPENSES'), 0, 0, 'L', true);
$this->Cell(50, 8, number_format($this->totalDepenses, 2, ',', ' ').' '.EURO, 0, 1, 'R', true);
$this->SetTextColor(0, 0, 0);
$this->Ln(8);
}
// ---------------------------------------------------------------
// Bilan
// ---------------------------------------------------------------
private function writeBilan(): void
{
$marge = $this->totalRecettes - $this->totalDepenses;
$isPositif = $marge >= 0;
$this->SetDrawColor(200, 200, 200);
$this->Cell(0, 0.5, '', 'T', 1, 'L');
$this->Ln(3);
$this->SetFont('Arial', 'B', 13);
$this->Cell(0, 7, $this->enc('BILAN'), 0, 1, 'C');
$this->Ln(2);
// Tableau bilan
$this->SetFont('Arial', '', 11);
$col1 = 90;
$col2 = 80;
$this->SetX(15);
$this->Cell($col1, 8, $this->enc('Total des recettes :'), 0, 0, 'L');
$this->SetFont('Arial', 'B', 11);
$this->SetTextColor(22, 163, 74);
$this->Cell($col2, 8, '+ '.number_format($this->totalRecettes, 2, ',', ' ').' '.EURO, 0, 1, 'R');
$this->SetTextColor(0, 0, 0);
$this->SetFont('Arial', '', 11);
$this->SetX(15);
$this->Cell($col1, 8, $this->enc('Total des depenses :'), 0, 0, 'L');
$this->SetFont('Arial', 'B', 11);
$this->SetTextColor(220, 38, 38);
$this->Cell($col2, 8, '- '.number_format($this->totalDepenses, 2, ',', ' ').' '.EURO, 0, 1, 'R');
$this->SetTextColor(0, 0, 0);
$this->Ln(1);
$this->SetDrawColor(0, 0, 0);
$this->Line(15, $this->GetY(), 185, $this->GetY());
$this->Ln(2);
// Resultat
$this->SetFont('Arial', 'B', 14);
$this->SetX(15);
$this->Cell($col1, 10, $this->enc('Resultat net :'), 0, 0, 'L');
$this->SetTextColor($isPositif ? 22 : 220, $isPositif ? 163 : 38, $isPositif ? 74 : 38);
$sign = $isPositif ? '+ ' : '- ';
$this->Cell($col2, 10, $sign.number_format(abs($marge), 2, ',', ' ').' '.EURO, 0, 1, 'R');
$this->SetTextColor(0, 0, 0);
// Statut
$this->Ln(3);
$this->SetFont('Arial', 'B', 12);
if ($isPositif) {
$this->SetFillColor(22, 163, 74);
$this->SetTextColor(255, 255, 255);
$label = $marge > $this->totalRecettes * 0.3 ? 'EXCEDENT' : 'EQUILIBRE';
} else {
$this->SetFillColor(220, 38, 38);
$this->SetTextColor(255, 255, 255);
$label = 'DEFICIT';
}
$this->Cell(0, 10, $this->enc($label), 0, 1, 'C', true);
$this->SetTextColor(0, 0, 0);
$this->Ln(5);
}
// ---------------------------------------------------------------
// Signature
// ---------------------------------------------------------------
private function writeSignatureBlock(): void
{
if ($this->GetY() + 40 > $this->GetPageHeight() - 25) {
$this->AddPage();
}
$this->SetDrawColor(200, 200, 200);
$this->Cell(0, 0.5, '', 'T', 1, 'L');
$this->Ln(3);
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::LONG,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$this->SetFont('Arial', '', 9);
$this->Cell(0, 5, $this->enc('Fait a Beautor, le '.$formatter->format(new \DateTime())), 0, 1, 'L');
$this->Ln(2);
$this->SetFont('Arial', 'B', 9);
$this->Cell(0, 5, $this->enc('Signature du responsable :'), 0, 1, 'L');
$this->Ln(1);
$this->SetAutoPageBreak(false);
$this->SetFont('Arial', '', 10);
$this->SetTextColor(0, 0, 0);
$this->Cell(60, 20, '{{Sign;type=signature;role=First Party}}', 0, 1, 'L');
}
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
private function enc(string $text): string
{
return mb_convert_encoding($text, 'Windows-1252', 'UTF-8');
}
}

View File

@@ -15,10 +15,13 @@ use Twig\Environment;
class RgpdService
{
private const CODE_EXPIRY_MINUTES = 15;
public function __construct(
private EntityManagerInterface $em,
private Environment $twig,
private DocuSealService $docuSealService,
private MailerService $mailer,
private UrlGeneratorInterface $urlGenerator,
#[Autowire('%kernel.project_dir%')] private string $projectDir,
#[Autowire('%env(APP_SECRET)%')] private string $hmacSecret,
@@ -49,6 +52,92 @@ class RgpdService
], UrlGeneratorInterface::ABSOLUTE_URL);
}
/**
* Genere et envoie un code de verification par email.
*/
public function sendVerificationCode(string $email, string $ip, string $type): void
{
$code = str_pad((string) random_int(0, 999999), 6, '0', \STR_PAD_LEFT);
// Stocke le code en session-like via un hash deterministe
$codeHash = $this->generateCodeHash($email, $ip, $type, $code);
// Stocke en fichier temporaire (expire dans 15 min)
$codePath = $this->getCodeFilePath($email, $ip, $type);
file_put_contents($codePath, json_encode([
'code' => $code,
'hash' => $codeHash,
'expires' => time() + (self::CODE_EXPIRY_MINUTES * 60),
]));
$typeName = 'access' === $type ? 'acces a vos donnees' : 'suppression de vos donnees';
$this->mailer->sendEmail(
$email,
'Code de verification RGPD - '.$typeName,
$this->twig->render('emails/rgpd_verification_code.html.twig', [
'code' => $code,
'type' => $type,
'typeName' => $typeName,
'expiryMinutes' => self::CODE_EXPIRY_MINUTES,
]),
null,
null,
false,
);
}
/**
* Verifie le code saisi par l'utilisateur.
*/
public function verifyCode(string $email, string $ip, string $type, string $code): bool
{
$codePath = $this->getCodeFilePath($email, $ip, $type);
if (!file_exists($codePath)) {
return false;
}
$data = json_decode(file_get_contents($codePath), true);
if (null === $data) {
@unlink($codePath);
return false;
}
// Expire ?
if (time() > ($data['expires'] ?? 0)) {
@unlink($codePath);
return false;
}
// Code correct ?
if ($code !== ($data['code'] ?? '')) {
return false;
}
// Supprime le fichier (usage unique)
@unlink($codePath);
return true;
}
private function getCodeFilePath(string $email, string $ip, string $type): string
{
$dir = $this->projectDir.'/var/rgpd/codes';
if (!is_dir($dir)) {
mkdir($dir, 0o755, true);
}
return $dir.'/'.hash('sha256', $email.'|'.$ip.'|'.$type.'|'.$this->hmacSecret).'.json';
}
private function generateCodeHash(string $email, string $ip, string $type, string $code): string
{
return hash_hmac('sha256', $email.'|'.$ip.'|'.$type.'|'.$code, $this->hmacSecret);
}
/**
* @return array{found: bool, count: int}
*/
@@ -139,7 +228,7 @@ class RgpdService
private function getLogoBase64(): string
{
$logoPath = $this->projectDir.'/public/logo_facture.png';
$logoPath = $this->projectDir.'/public/logo.jpg';
return file_exists($logoPath) ? 'data:image/jpeg;base64,'.base64_encode((string) file_get_contents($logoPath)) : '';
}

View File

@@ -0,0 +1,173 @@
<?php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Wrapper API Sentry pour gerer les projets, issues et DSN programmatiquement.
*
* @codeCoverageIgnore
*/
class SentryService
{
public function __construct(
private HttpClientInterface $httpClient,
private LoggerInterface $logger,
#[Autowire(env: 'SENTRY_AUTH_TOKEN')] private string $authToken = '',
#[Autowire(env: 'SENTRY_ORG')] private string $defaultOrg = '',
#[Autowire(env: 'SENTRY_API_URL')] private string $apiUrl = 'https://sentry.io/api/0',
) {
}
public function isAvailable(): bool
{
return '' !== $this->authToken;
}
/**
* Cree un projet Sentry dans une equipe.
*
* @return array<string, mixed>|null
*/
public function createProject(string $teamSlug, string $name, string $org = ''): ?array
{
$org = $this->resolveOrg($org);
return $this->request('POST', '/teams/'.$org.'/'.$teamSlug.'/projects/', ['name' => $name]);
}
public function deleteProject(string $projectSlug, string $org = ''): bool
{
$org = $this->resolveOrg($org);
return null !== $this->request('DELETE', '/projects/'.$org.'/'.$projectSlug.'/');
}
/**
* Recupere le DSN public du projet.
*/
public function getProjectDsn(string $projectSlug, string $org = ''): ?string
{
$org = $this->resolveOrg($org);
$result = $this->request('GET', '/projects/'.$org.'/'.$projectSlug.'/keys/');
if (null === $result || !isset($result[0]['dsn']['public'])) {
return null;
}
return $result[0]['dsn']['public'];
}
/**
* Liste tous les projets de l'organisation.
*
* @return list<array<string, mixed>>
*/
public function listProjects(string $org = ''): array
{
$org = $this->resolveOrg($org);
return $this->request('GET', '/organizations/'.$org.'/projects/') ?? [];
}
/**
* Liste les issues/erreurs d'un projet.
*
* @return list<array<string, mixed>>
*/
public function listProjectErrors(string $projectSlug, ?string $query = null, string $org = ''): array
{
$org = $this->resolveOrg($org);
$path = '/projects/'.$org.'/'.$projectSlug.'/issues/';
if (null !== $query && '' !== $query) {
$path .= '?query='.urlencode($query);
}
return $this->request('GET', $path) ?? [];
}
/**
* Recupere les details d'une issue.
*
* @return array<string, mixed>|null
*/
public function getIssueDetails(string $issueId): ?array
{
return $this->request('GET', '/issues/'.$issueId.'/');
}
/**
* Marque une issue comme resolue.
*/
public function resolveIssue(string $issueId): bool
{
return null !== $this->request('PUT', '/issues/'.$issueId.'/', ['status' => 'resolved']);
}
/**
* Supprime une issue.
*/
public function deleteIssue(string $issueId): bool
{
return null !== $this->request('DELETE', '/issues/'.$issueId.'/');
}
/**
* @return array<string, mixed>|null
*/
private function request(string $method, string $path, ?array $body = null): ?array
{
if (!$this->isAvailable()) {
$this->logger->warning('SentryService: auth token non configure');
return null;
}
try {
$options = [
'headers' => [
'Authorization' => 'Bearer '.$this->authToken,
'Content-Type' => 'application/json',
],
];
if (null !== $body) {
$options['json'] = $body;
}
$response = $this->httpClient->request($method, rtrim($this->apiUrl, '/').$path, $options);
$statusCode = $response->getStatusCode();
if ($statusCode >= 400) {
$this->logger->error('SentryService: HTTP '.$statusCode.' '.$method.' '.$path, [
'body' => $response->getContent(false),
]);
return null;
}
// DELETE retourne 204 sans body
if (204 === $statusCode || '' === $response->getContent(false)) {
return [];
}
return $response->toArray();
} catch (\Throwable $e) {
$this->logger->error('SentryService: '.$e->getMessage(), [
'method' => $method,
'path' => $path,
]);
return null;
}
}
private function resolveOrg(string $org): string
{
return '' !== $org ? $org : $this->defaultOrg;
}
}

523
src/Service/SeoService.php Normal file
View File

@@ -0,0 +1,523 @@
<?php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* @codeCoverageIgnore
*/
class SeoService
{
private const USER_AGENT = 'E-Cosplay-SEO-Checker/1.0';
private const TIMEOUT = 10;
public function __construct(
private HttpClientInterface $httpClient,
private LoggerInterface $logger,
) {
}
/**
* Analyse les meta tags d'une page (title, description, og:*, canonical).
*
* @return array{title: ?string, description: ?string, og_title: ?string, og_description: ?string, og_image: ?string, canonical: ?string, issues: list<string>}
*/
public function checkMetaTags(string $url): array
{
$result = [
'title' => null,
'description' => null,
'og_title' => null,
'og_description' => null,
'og_image' => null,
'canonical' => null,
'issues' => [],
];
$html = $this->fetchHtml($url);
if (null === $html) {
$result['issues'][] = 'Impossible de recuperer la page';
return $result;
}
// Title
if (preg_match('/<title[^>]*>(.*?)<\/title>/si', $html, $m)) {
$result['title'] = trim(html_entity_decode($m[1], \ENT_QUOTES, 'UTF-8'));
} else {
$result['issues'][] = 'Balise <title> manquante';
}
if (null !== $result['title'] && \strlen($result['title']) > 60) {
$result['issues'][] = 'Title trop long ('.\strlen($result['title']).' caracteres, max 60)';
}
// Meta description
$result['description'] = $this->extractMetaTag($html, 'description');
if (null === $result['description']) {
$result['issues'][] = 'Meta description manquante';
} elseif (\strlen($result['description']) > 160) {
$result['issues'][] = 'Meta description trop longue ('.\strlen($result['description']).' caracteres, max 160)';
}
// Open Graph
$result['og_title'] = $this->extractMetaTag($html, 'og:title', 'property');
$result['og_description'] = $this->extractMetaTag($html, 'og:description', 'property');
$result['og_image'] = $this->extractMetaTag($html, 'og:image', 'property');
if (null === $result['og_title']) {
$result['issues'][] = 'Meta og:title manquante';
}
if (null === $result['og_image']) {
$result['issues'][] = 'Meta og:image manquante';
}
// Canonical
if (preg_match('/<link[^>]+rel=["\']canonical["\'][^>]+href=["\']([^"\']+)["\']/i', $html, $m)) {
$result['canonical'] = $m[1];
} else {
$result['issues'][] = 'Balise canonical manquante';
}
return $result;
}
/**
* Verifie le fichier sitemap.xml.
*
* @return array{exists: bool, valid: bool, url_count: int, issues: list<string>}
*/
public function checkSitemap(string $siteUrl): array
{
$result = ['exists' => false, 'valid' => false, 'url_count' => 0, 'issues' => []];
$sitemapUrl = rtrim($siteUrl, '/').'/sitemap.xml';
try {
$response = $this->httpClient->request('GET', $sitemapUrl, [
'timeout' => self::TIMEOUT,
'headers' => ['User-Agent' => self::USER_AGENT],
]);
if (200 !== $response->getStatusCode()) {
$result['issues'][] = 'sitemap.xml retourne HTTP '.$response->getStatusCode();
return $result;
}
$result['exists'] = true;
$content = $response->getContent();
libxml_use_internal_errors(true);
$xml = simplexml_load_string($content);
if (false === $xml) {
$result['issues'][] = 'sitemap.xml invalide (XML malformed)';
return $result;
}
$result['valid'] = true;
$xml->registerXPathNamespace('sm', 'http://www.sitemaps.org/schemas/sitemap/0.9');
$urls = $xml->xpath('//sm:url');
$result['url_count'] = false !== $urls ? \count($urls) : 0;
if (0 === $result['url_count']) {
// Essai sans namespace
$urls = $xml->xpath('//url');
$result['url_count'] = false !== $urls ? \count($urls) : 0;
}
} catch (\Throwable $e) {
$result['issues'][] = 'Erreur acces sitemap.xml : '.$e->getMessage();
$this->logger->error('SeoService::checkSitemap: '.$e->getMessage(), ['url' => $sitemapUrl]);
}
return $result;
}
/**
* Verifie le fichier robots.txt.
*
* @return array{exists: bool, content: string, has_sitemap: bool, has_disallow: bool, issues: list<string>}
*/
public function checkRobotsTxt(string $siteUrl): array
{
$result = ['exists' => false, 'content' => '', 'has_sitemap' => false, 'has_disallow' => false, 'issues' => []];
$robotsUrl = rtrim($siteUrl, '/').'/robots.txt';
try {
$response = $this->httpClient->request('GET', $robotsUrl, [
'timeout' => self::TIMEOUT,
'headers' => ['User-Agent' => self::USER_AGENT],
]);
if (200 !== $response->getStatusCode()) {
$result['issues'][] = 'robots.txt retourne HTTP '.$response->getStatusCode();
return $result;
}
$result['exists'] = true;
$result['content'] = $response->getContent();
$result['has_sitemap'] = (bool) preg_match('/^Sitemap:/mi', $result['content']);
$result['has_disallow'] = (bool) preg_match('/^Disallow:/mi', $result['content']);
if (!$result['has_sitemap']) {
$result['issues'][] = 'robots.txt ne reference pas de Sitemap';
}
} catch (\Throwable $e) {
$result['issues'][] = 'Erreur acces robots.txt : '.$e->getMessage();
}
return $result;
}
/**
* Verifie la presence du meta viewport (responsive).
*
* @return array{has_viewport: bool, viewport_content: ?string, issues: list<string>}
*/
public function checkMobileResponsive(string $url): array
{
$result = ['has_viewport' => false, 'viewport_content' => null, 'issues' => []];
$html = $this->fetchHtml($url);
if (null === $html) {
$result['issues'][] = 'Impossible de recuperer la page';
return $result;
}
$viewport = $this->extractMetaTag($html, 'viewport');
if (null !== $viewport) {
$result['has_viewport'] = true;
$result['viewport_content'] = $viewport;
} else {
$result['issues'][] = 'Meta viewport manquante (site non responsive)';
}
return $result;
}
/**
* Mesure le temps de reponse HTTP.
*
* @return array{load_time_ms: int, status_code: int, issues: list<string>}
*/
public function checkPageSpeed(string $url): array
{
$result = ['load_time_ms' => 0, 'status_code' => 0, 'issues' => []];
try {
$start = microtime(true);
$response = $this->httpClient->request('GET', $url, [
'timeout' => 30,
'headers' => ['User-Agent' => self::USER_AGENT],
]);
$response->getContent();
$result['load_time_ms'] = (int) ((microtime(true) - $start) * 1000);
$result['status_code'] = $response->getStatusCode();
if ($result['load_time_ms'] > 3000) {
$result['issues'][] = 'Page lente ('.$result['load_time_ms'].'ms, seuil recommande 3s)';
}
} catch (\Throwable $e) {
$result['issues'][] = 'Erreur chargement : '.$e->getMessage();
}
return $result;
}
/**
* Verifie le certificat SSL.
*
* @return array{is_https: bool, valid: bool, issuer: ?string, expires: ?string, days_remaining: ?int, issues: list<string>}
*/
public function checkSslCertificate(string $url): array
{
$result = ['is_https' => false, 'valid' => false, 'issuer' => null, 'expires' => null, 'days_remaining' => null, 'issues' => []];
$parsed = parse_url($url);
if (!isset($parsed['host'])) {
$result['issues'][] = 'URL invalide';
return $result;
}
$result['is_https'] = 'https' === ($parsed['scheme'] ?? '');
if (!$result['is_https']) {
$result['issues'][] = 'Site non HTTPS';
return $result;
}
$host = $parsed['host'];
$port = $parsed['port'] ?? 443;
try {
$context = stream_context_create(['ssl' => ['capture_peer_cert' => true, 'verify_peer' => false]]);
$socket = @stream_socket_client('ssl://'.$host.':'.$port, $errno, $errstr, 10, \STREAM_CLIENT_CONNECT, $context);
if (false === $socket) {
$result['issues'][] = 'Connexion SSL impossible : '.$errstr;
return $result;
}
$params = stream_context_get_params($socket);
fclose($socket);
$cert = $params['options']['ssl']['peer_certificate'] ?? null;
if (null === $cert) {
$result['issues'][] = 'Certificat non recuperable';
return $result;
}
$certInfo = openssl_x509_parse($cert);
if (false === $certInfo) {
$result['issues'][] = 'Certificat non parsable';
return $result;
}
$result['valid'] = true;
$result['issuer'] = $certInfo['issuer']['O'] ?? $certInfo['issuer']['CN'] ?? null;
$result['expires'] = date('Y-m-d', $certInfo['validTo_time_t']);
$result['days_remaining'] = (int) ((($certInfo['validTo_time_t']) - time()) / 86400);
if ($result['days_remaining'] < 0) {
$result['valid'] = false;
$result['issues'][] = 'Certificat expire depuis '.abs($result['days_remaining']).' jours';
} elseif ($result['days_remaining'] < 30) {
$result['issues'][] = 'Certificat expire dans '.$result['days_remaining'].' jours';
}
} catch (\Throwable $e) {
$result['issues'][] = 'Erreur verification SSL : '.$e->getMessage();
}
return $result;
}
/**
* Verifie les headers de securite.
*
* @return array{headers: array<string, string>, missing: list<string>, issues: list<string>}
*/
public function checkHeaders(string $url): array
{
$result = ['headers' => [], 'missing' => [], 'issues' => []];
$expected = ['X-Frame-Options', 'Content-Security-Policy', 'Strict-Transport-Security', 'X-Content-Type-Options'];
try {
$response = $this->httpClient->request('HEAD', $url, [
'timeout' => self::TIMEOUT,
'headers' => ['User-Agent' => self::USER_AGENT],
]);
$responseHeaders = $response->getHeaders(false);
foreach ($expected as $header) {
$lower = strtolower($header);
if (isset($responseHeaders[$lower])) {
$result['headers'][$header] = implode(', ', $responseHeaders[$lower]);
} else {
$result['missing'][] = $header;
$result['issues'][] = 'Header '.$header.' manquant';
}
}
} catch (\Throwable $e) {
$result['issues'][] = 'Erreur verification headers : '.$e->getMessage();
}
return $result;
}
/**
* Demande a Google de re-indexer une URL (purge cache + reindex).
* Utilise GoogleSearchService::submitUrlForIndexing en interne.
*
* @return array{success: bool, url: string, message: string}
*/
public function purgeAndReindex(string $url, GoogleSearchService $googleSearch): array
{
if (!$googleSearch->isAvailable()) {
return ['success' => false, 'url' => $url, 'message' => 'Google Search Console non configure'];
}
$result = $googleSearch->submitUrlForIndexing($url);
if ($result) {
$this->logger->info('SeoService::purgeAndReindex: URL soumise pour reindexation', ['url' => $url]);
return ['success' => true, 'url' => $url, 'message' => 'URL soumise a Google pour reindexation'];
}
$this->logger->error('SeoService::purgeAndReindex: echec soumission URL', ['url' => $url]);
return ['success' => false, 'url' => $url, 'message' => 'Echec de la soumission a Google Indexing API'];
}
/**
* Purge et reindexe toutes les URLs d'un sitemap.
*
* @return array{total: int, success: int, failed: int, results: list<array{success: bool, url: string, message: string}>}
*/
public function purgeAllFromSitemap(string $siteUrl, GoogleSearchService $googleSearch): array
{
$sitemap = $this->checkSitemap($siteUrl);
$results = ['total' => 0, 'success' => 0, 'failed' => 0, 'results' => []];
if (!$sitemap['exists'] || !$sitemap['valid']) {
return $results;
}
$sitemapUrl = rtrim($siteUrl, '/').'/sitemap.xml';
try {
$response = $this->httpClient->request('GET', $sitemapUrl, [
'timeout' => self::TIMEOUT,
'headers' => ['User-Agent' => self::USER_AGENT],
]);
$xml = simplexml_load_string($response->getContent());
if (false === $xml) {
return $results;
}
$xml->registerXPathNamespace('sm', 'http://www.sitemaps.org/schemas/sitemap/0.9');
$urls = $xml->xpath('//sm:url/sm:loc') ?: $xml->xpath('//url/loc') ?: [];
foreach ($urls as $loc) {
$pageUrl = (string) $loc;
$r = $this->purgeAndReindex($pageUrl, $googleSearch);
$results['results'][] = $r;
++$results['total'];
$r['success'] ? ++$results['success'] : ++$results['failed'];
}
} catch (\Throwable $e) {
$this->logger->error('SeoService::purgeAllFromSitemap: '.$e->getMessage());
}
return $results;
}
/**
* Score SEO global (0-100) base sur tous les checks.
*
* @return array{score: int, details: array<string, mixed>}
*/
public function getPageScore(string $url): array
{
$parsed = parse_url($url);
$siteUrl = ($parsed['scheme'] ?? 'https').'://'.($parsed['host'] ?? '');
$meta = $this->checkMetaTags($url);
$sitemap = $this->checkSitemap($siteUrl);
$robots = $this->checkRobotsTxt($siteUrl);
$mobile = $this->checkMobileResponsive($url);
$speed = $this->checkPageSpeed($url);
$ssl = $this->checkSslCertificate($url);
$headers = $this->checkHeaders($url);
$score = 100;
// Meta tags (30 points max)
if (null === $meta['title']) {
$score -= 10;
}
if (null === $meta['description']) {
$score -= 10;
}
if (null === $meta['og_title']) {
$score -= 5;
}
if (null === $meta['canonical']) {
$score -= 5;
}
// Sitemap + robots (15 points)
if (!$sitemap['exists']) {
$score -= 10;
}
if (!$robots['exists']) {
$score -= 5;
}
// Mobile (10 points)
if (!$mobile['has_viewport']) {
$score -= 10;
}
// Speed (15 points)
if ($speed['load_time_ms'] > 5000) {
$score -= 15;
} elseif ($speed['load_time_ms'] > 3000) {
$score -= 10;
} elseif ($speed['load_time_ms'] > 1500) {
$score -= 5;
}
// SSL (15 points)
if (!$ssl['is_https']) {
$score -= 15;
} elseif (!$ssl['valid']) {
$score -= 10;
}
// Headers (15 points)
$score -= \count($headers['missing']) * 4;
return [
'score' => max(0, $score),
'details' => [
'meta' => $meta,
'sitemap' => $sitemap,
'robots' => $robots,
'mobile' => $mobile,
'speed' => $speed,
'ssl' => $ssl,
'headers' => $headers,
],
];
}
private function fetchHtml(string $url): ?string
{
try {
$response = $this->httpClient->request('GET', $url, [
'timeout' => self::TIMEOUT,
'headers' => ['User-Agent' => self::USER_AGENT],
]);
if (200 !== $response->getStatusCode()) {
return null;
}
return $response->getContent();
} catch (\Throwable $e) {
$this->logger->error('SeoService::fetchHtml: '.$e->getMessage(), ['url' => $url]);
return null;
}
}
private function extractMetaTag(string $html, string $name, string $attribute = 'name'): ?string
{
$pattern = '/<meta[^>]+'.preg_quote($attribute, '/').'=["\']'.preg_quote($name, '/').
'["\'][^>]+content=["\']([^"\']*)["\'][^>]*>/i';
if (preg_match($pattern, $html, $m)) {
return trim(html_entity_decode($m[1], \ENT_QUOTES, 'UTF-8'));
}
// Ordre inverse : content avant name/property
$pattern2 = '/<meta[^>]+content=["\']([^"\']*)["\'][^>]+'.preg_quote($attribute, '/').'=["\']'.preg_quote($name, '/').
'["\'][^>]*>/i';
if (preg_match($pattern2, $html, $m)) {
return trim(html_entity_decode($m[1], \ENT_QUOTES, 'UTF-8'));
}
return null;
}
}

View File

@@ -25,9 +25,9 @@ class StripeWebhookService
'invoice.finalized',
'invoice.payment_succeeded',
'invoice.payment_failed',
'subscription.created',
'subscription.updated',
'subscription.deleted',
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
];
/** Evenements instantanes (paiements, actions critiques) */
@@ -104,7 +104,7 @@ class StripeWebhookService
'url' => $wh['url'],
'enabled_events' => $wh['events'],
'connect' => $wh['connect'],
'description' => 'CRM SITECONSEIL - '.$wh['type'],
'description' => 'CRM E-Cosplay - '.$wh['type'],
]);
$created[] = ['type' => $wh['type'], 'url' => $wh['url'], 'id' => $endpoint->id, 'status' => 'created', 'secret' => $endpoint->secret];

View File

@@ -19,75 +19,109 @@ class TarificationService
// NOSONAR - data configuration, not duplicated logic
private const DEFAULT_PRICES = [
'esyweb_business' => [
'title' => 'Esy-Web Business',
'description' => 'Licence Esy-Web + installation technique. Le client remplit le site en autonomie.',
'esite_business' => [
'title' => 'E-Site Basique',
'description' => 'Creation, hebergement et maintenance de site internet realise avec Esy-Web. Le client remplit le site en autonomie.',
'priceHt' => self::PRICE_500,
'monthPrice' => self::PRICE_100,
'period' => 1,
'lineType' => 'website',
],
'esyweb_premium' => [
'title' => 'Esy-Web Premium',
'description' => 'Creation complete du site par SITECONSEIL avec accompagnement jusqu\'a la mise en ligne.',
'priceHt' => '3200.00',
'esite_premium' => [
'title' => 'E-Site Avancee',
'description' => 'Creation, hebergement et maintenance de site internet realise avec Esy-Web. Accompagnement complet jusqu\'a la mise en ligne.',
'priceHt' => '2000.00',
'monthPrice' => self::PRICE_100,
'period' => 1,
'lineType' => 'website',
],
'ecommerce_business' => [
'title' => 'E-Commerce Business',
'description' => 'Licence Esy-Web full options + E-boutique. Produits illimites.',
'esite_ecommerce_business' => [
'title' => 'E-Site E-Commerce Basique',
'description' => 'Creation, hebergement et maintenance de site E-Commerce realise avec Esy-Web. Produits illimites.',
'priceHt' => '999.00',
'monthPrice' => '150.00',
'period' => 1,
'lineType' => 'website',
],
'ecommerce_premium' => [
'title' => 'E-Commerce Premium',
'description' => 'Creation complete du site E-Commerce par SITECONSEIL. Produits illimites.',
'priceHt' => '5110.00',
'esite_ecommerce_premium' => [
'title' => 'E-Site E-Commerce Avancee',
'description' => 'Creation, hebergement et maintenance de site E-Commerce realise avec Esy-Web. Accompagnement complet. Produits illimites.',
'priceHt' => '3100.00',
'monthPrice' => '150.00',
'period' => 1,
'lineType' => 'website',
],
'esymail' => [
'title' => 'Esy-Mail',
'description' => 'Messagerie professionnelle. 2 boites mail, 5 Go, antispam, RGPD.',
'priceHt' => self::PRICE_50,
'monthPrice' => self::PRICE_30,
'email_3go' => [
'title' => 'E-Mail 3 Go',
'description' => 'Boite mail professionnelle 3 Go. Antispam, antivirus, RGPD, IMAP/POP/SMTP.',
'priceHt' => '1.00',
'monthPrice' => '1.00',
'period' => 1,
'lineType' => 'esymail',
],
'esymailer' => [
'title' => 'Esy-Mailer',
'email_50go' => [
'title' => 'E-Mail 50 Go',
'description' => 'Boite mail professionnelle 50 Go. Antispam, antivirus, RGPD, IMAP/POP/SMTP.',
'priceHt' => '5.00',
'monthPrice' => '5.00',
'period' => 1,
'lineType' => 'esymail',
],
'email_mise_en_service' => [
'title' => 'E-Mail - Mise en service',
'description' => 'Mise en service de la messagerie professionnelle E-Mail.',
'priceHt' => '25.00',
'monthPrice' => '0.00',
'period' => 1,
'lineType' => 'esymail',
],
'email_supp_3go' => [
'title' => 'E-Mail - Boite supplementaire 3 Go',
'description' => 'Boite mail supplementaire 3 Go. Antispam, antivirus, RGPD.',
'priceHt' => '1.00',
'monthPrice' => '1.00',
'period' => 1,
'lineType' => 'esymail',
],
'email_supp_50go' => [
'title' => 'E-Mail - Boite supplementaire 50 Go',
'description' => 'Boite mail supplementaire 50 Go. Antispam, antivirus, RGPD.',
'priceHt' => '5.00',
'monthPrice' => '5.00',
'period' => 1,
'lineType' => 'esymail',
],
'emailer' => [
'title' => 'E-Mailer',
'description' => 'Envoi de mail en masse. 15 000 mails/mois.',
'priceHt' => self::PRICE_50,
'monthPrice' => self::PRICE_30,
'period' => 1,
'lineType' => 'esymail',
],
'esydefender_pro' => [
'title' => 'Esy-Defender Pro',
'title' => 'E-Protect Pro',
'description' => 'Cyber defense avancee. Anti-DDoS, pare-feu, filtrage geo, surveillance.',
'priceHt' => self::PRICE_50,
'monthPrice' => '60.00',
'period' => 3,
'lineType' => 'hosting',
],
'esymeet' => [
'title' => 'Esy-Meet',
'title' => 'E-Calendar',
'description' => 'Prise de rendez-vous en ligne via Cal.com.',
'priceHt' => self::PRICE_50,
'monthPrice' => self::PRICE_30,
'period' => 1,
'lineType' => 'website',
],
'esytchat' => [
'title' => 'Esy-Tchat',
'title' => 'E-Chat',
'description' => 'Chat en ligne sur votre site via Chatwoot.',
'priceHt' => self::PRICE_50,
'monthPrice' => '15.00',
'period' => 1,
],
'esycreator' => [
'title' => 'Esy-Creator',
'description' => 'Maintenance graphique et editoriale du site.',
'priceHt' => self::PRICE_500,
'monthPrice' => self::PRICE_100,
'period' => 3,
'lineType' => 'website',
],
'ndd_depot' => [
'title' => 'Nom de domaine - Depot',
@@ -95,6 +129,7 @@ class TarificationService
'priceHt' => '20.00',
'monthPrice' => '0.00',
'period' => 1,
'lineType' => 'ndd',
],
'ndd_renouvellement' => [
'title' => 'Nom de domaine - Renouvellement',
@@ -102,6 +137,7 @@ class TarificationService
'priceHt' => '20.00',
'monthPrice' => '0.00',
'period' => 12,
'lineType' => 'ndd',
],
'ndd_gestion' => [
'title' => 'Nom de domaine - Gestion',
@@ -109,6 +145,7 @@ class TarificationService
'priceHt' => self::PRICE_30,
'monthPrice' => '0.00',
'period' => 12,
'lineType' => 'ndd',
],
'ndd_reactivation' => [
'title' => 'Nom de domaine - Reactivation',
@@ -116,6 +153,7 @@ class TarificationService
'priceHt' => self::PRICE_50,
'monthPrice' => '0.00',
'period' => 1,
'lineType' => 'ndd',
],
'formation_pack10h' => [
'title' => 'Pack 10 heures de formation',
@@ -123,6 +161,7 @@ class TarificationService
'priceHt' => self::PRICE_500,
'monthPrice' => '0.00',
'period' => 1,
'lineType' => 'other',
],
'formation_heure' => [
'title' => 'Formation a la demande',
@@ -130,6 +169,7 @@ class TarificationService
'priceHt' => '70.00',
'monthPrice' => '0.00',
'period' => 1,
'lineType' => 'other',
],
];

Some files were not shown because too many files have changed in this diff Show More