← Back to work
2022 · B2B marketplace · Odoo platform

MySpecialist

A Belgian SaaS marketplace that pairs customers with vetted independent specialists, across Belgium and Switzerland. Inherited in 2022 as a Symfony tier on Odoo 8 — rebuilt as clean Django on Odoo 16, and run by the same three engineers ever since.

Role
Full-stack implementation · architecture · QA · DevOps
Duration
Since 2022, ongoing
Team
3 engineers
PROJECT HERO · PLACEHOLDER
FIG. 01
Context

MySpecialist is a Belgian SaaS marketplace that pairs customers needing specialist work with vetted independent providers, across Belgium and Switzerland. The platform handles the full lifecycle — inbound quote requests, provider matching and acceptance, scheduling, billing, Peppol e-invoicing, post-job satisfaction — in three languages (FR / NL / EN) and two markets, out of one codebase.

We were engaged in 2022 to take it over end-to-end. What we inherited was a Symfony tier that had accreted as fast as features had shipped — hand-rolled forms, no test suite, internal-tool layer entangled with business logic — running against an Odoo 8 instance five major versions behind. The first stretch of the engagement was a rescue: the Symfony layer was rebuilt as a clean Django app, and the Odoo side was migrated from 8 to 16 through a custom ETL pipeline — customers, orders, invoices, message threads, moved schema-by-schema and validated against the old database before cutover.

That's been the foundation of every shipped feature since 2023. Four years and over thirteen hundred commits in, we're still the engineering team behind it — the same three people, the same codebase, and a deployment policy that hasn't produced a destructive incident.

The interesting parts aren't where you'd expect a marketplace's interesting parts to be. They sit in the seams: Belgian e-invoicing law, two-sided ownership with a shared customer pool, a CRM pipeline that has to self-advance only when three boolean gates all flip green, and an Odoo 19 EDI module that had to be carefully translated back into Odoo 16 idioms.

Scope

What we built.

myspecialist_base01

Foundation menus, core dependencies, l10n_be wiring.

myspecialist_sale02

~20 models: prescriber workflows, commissions, CRM leads, portal, promos, testimonials.

myspecialist_peppol03

Peppol EDI: invoice signing, sending, receiving — backported from Odoo 19.

myspecialist_localities04

Country / region / city hierarchy across FR / EN / NL.

myspecialist_lang05

Active-language standardization — en_BE and en_CH replace en_US / fr_FR with a translation migration.

myspecialist_sms06

Pushbullet notifications, SMS configuration, message templates.

myspecialist_brevo_sync07

One-way Odoo → Brevo contact sync with E.164 phone normalization and a nightly catch-up cron.

myspecialist_cdn08

CDN URL management for static assets.

myspecialist_press09

Press release management.

myspecialist_project_pictures10

Project galleries.

s3_backup11

AWS S3 database backups.

Django application12

Rebuilt from the inherited Symfony codebase (2022). Shares the Odoo Postgres database; carries the tools and flows that sit better outside Odoo.

Approach

What the work looked like, in four pieces.

01

Domain model & CRM pipeline

Translated the two-sided marketplace into Odoo's CRM: prescribers, providers, MySpecialist's shared customer pool, an independent-customer ownership model. A nine-stage opportunity pipeline that auto-advances when its qualification gates all flip green, with per-group access rules that let staff, prescribers and independents see only their slice.

02

Peppol & Belgian compliance

Backported Odoo 19's account_peppol module to Odoo 16 — proxy authentication, RSA-2048 key pair management, batched outbound documents, Helger XML validation with SHA-256 cached results. Belgian-specific EAS overrides so the EndpointID in the XML matches the API receiver field. Stuck invoices get a revalidate button; stale registrations notify the team instead of being silently archived.

03

Tests, environments, deploy

Playwright smoke suites split by market (BE / CH) and tier (office / prod), plus an end-to-end pipeline covering quote → invoice → payment → feedback. A two-environment policy — every staging push triggers an office deploy and a follow-up module upgrade via XML-RPC; prod deploys are hand-driven and explicit. Side-effect archiving for tests so they never leak mail.

04

Rescue, migration, longevity

The first stretch was a rescue — the Symfony tier rebuilt as a clean Django app, the Odoo side migrated from 8 to 16 through a custom ETL pipeline. Since then the platform has held its shape across eleven modules, two markets, three languages and a stable Odoo 16 baseline. Architecture decisions are written down in CLAUDE.md so any of us can pick up where another left off.

Engineering highlights

A handful of the solves we are proudest of.

01

Symfony spaghetti → clean Django port

Year one of the engagement was largely rescue. The Symfony codebase had accreted as fast as features had shipped — hand-rolled forms, no test suite, internal-tool layer entangled with business logic. We rebuilt it as a Django app sharing the Odoo Postgres database: clear ORM boundaries, a real test suite, and the same team now extending features in days instead of weeks.

02

Odoo 8 → 16 migration via ETL

The original platform ran on Odoo 8 — five major versions behind. We wrote a custom ETL pipeline to move customers, orders, invoices and message threads forward to Odoo 16, mapping schemas as the models had changed underneath them. Each batch was validated against the old database before cutover. The Odoo 16 era began in 2023 with no record loss.

03

Peppol backport, Odoo 19 → 16

Re-implemented account_peppol against the older send wizard (account.invoice.send vs. Odoo 19's account.move.send). XML is generated at posting via account_edi_ubl_cii._ubl_cii_post_invoice and explicitly regenerated before re-send, since the stored attachment can drift. Test infrastructure mocks the proxy via a common fixture.

04

EAS override for Belgian partners

Odoo 16 core ships COUNTRY_EAS['BE'] = 9925, but Belgian Peppol partners actually use EAS 0208. Overrode account.edi.xml.ubl_bis3._get_partner_party_vals so the XML EndpointID matches the receiver field on the API call — without this, the proxy rejects with error 103 and nothing ships.

05

Bank dedup on incoming docs

Odoo 16's _import_retrieve_and_fill_partner_bank_details sanitizes its input but searches res.partner.bank by the raw acc_number, hitting the unique-constraint when the stored account has formatting like 'BE55 7350 7167 8944'. Overrode to search by sanitized_acc_number, with clean_context to match core behaviour.

06

Recursion prevention via threading.local

Outgoing Peppol calls go through an Odoo proxy that can itself be called by Odoo. Wrapped requests in threading.local() flags so a proxy call inside a proxy call short-circuits rather than recursing under load.

07

Stuck-invoice revalidation

When an invoice gets stuck mid-Peppol-flow, an action_revalidate_peppol_xml button regenerates the attachment, re-runs Helger validation, and posts the result to chatter via account.edi.common._check_xml_ecosio. The team sees what went wrong and what was tried, in the document itself.

08

Two-environment deploy policy

Office (staging) and prod follow opposite rules: every staging push is followed by an office deploy + module upgrade; prod deploys only happen on explicit human ask. Four years in, no destructive incident from a deploy across either environment.

Outcomes

A few shapes, in their raw form.

11
Custom Odoo modules
BE · CH
Markets in production
FR · NL · EN
Languages, one codebase
4+ yrs
Same team, same codebase

Stack
Odoo 16PythonPostgreSQLDjangoPeppolPlaywrightDockerNginxAWS S3GitHub Actions

Have a project that deserves this kind of care?

Start a conversation