DEV Community

Cover image for How we mapped EU renovation subsidies into one JSON response
bennaceur walid
bennaceur walid

Posted on

How we mapped EU renovation subsidies into one JSON response

A homeowner in Lille wants to insulate their roof and install a heat pump. They could be eligible for MaPrimeRenov, CEE certificates, a zero-interest Eco-PTZ loan, and reduced VAT — four separate schemes, each with its own income thresholds, work-type restrictions, and ceiling amounts. Cross the border into Belgium and it's a different set of regional premiums. Go to Germany and you're dealing with KfW loans and BAFA grants.

We built GreenCalc to turn all of that into a single POST request. Here's what we learned.

The problem: 15 schemes, 5 countries, rules that change every January

The EU renovation subsidy landscape is a mess — by design. Each country runs its own programs with its own eligibility rules. Here's what we're dealing with:

Country Main schemes Income-based? Climate zones? Update frequency
France MaPrimeRenov, CEE, Eco-PTZ, TVA 5.5% Yes (4 categories) Yes (H1/H2/H3) January + July
Germany KfW 262, BEG EM, BAFA audit No (flat rates) No Variable
Italy Superbonus, Ecobonus, Bonus Casa No No When the government changes
Belgium Primes Wallonie, Renolution Bruxelles, Mijn VerbouwPremie Yes (per region) No January
Spain PREE, MOVES III Partially No Variable

A fintech developer who wants to show users "how much subsidy can you get?" would need to become a regulatory expert in five countries. Or call one API.

Decision #1: Config-driven, not code-driven

The most important architecture decision was this: subsidy rates live in the database, not in Java code.

When France updates MaPrimeRenov thresholds every January, we run one SQL statement:

UPDATE greencalc.subsidy_scheme_rates
SET    rate_value = 30.00,
       updated_at = NOW()
WHERE  country_code   = 'FR'
  AND  scheme_code    = 'ma_prime_renov'
  AND  work_type      = 'ROOF_INSULATION'
  AND  income_category = 'MODEST';
Enter fullscreen mode Exit fullscreen mode

No code change. No JAR rebuild. No deployment. The rate is read from PostgreSQL on every request, so changes are live instantly.

The subsidy_scheme_rates table has this shape:

country_code | scheme_code     | work_type        | income_category | rate_type  | rate_value | max_amount | unit
FR           | ma_prime_renov  | ROOF_INSULATION  | VERY_MODEST     | FIXED      | 75.00      | 6000.00    | PER_M2
FR           | ma_prime_renov  | ROOF_INSULATION  | MODEST          | FIXED      | 60.00      | 4800.00    | PER_M2
FR           | cee             | ROOF_INSULATION  | ALL             | FIXED      | 12.00      | NULL       | PER_M2
DE           | kfw_beg         | HEAT_PUMP        | ALL             | PERCENTAGE | 25.00      | 15000.00   | FIXED
Enter fullscreen mode Exit fullscreen mode

This means non-technical staff can update rates with a SQL client. The code only contains the calculation logic — how to combine rates with household income, surface area, and work types.

Decision #2: The Strategy pattern for multi-country logic

Each country has wildly different rules. France has income-based categories that depend on climate zone. Germany has flat percentage grants. Italy has cascading bonuses that interact with each other.

We needed each country to be its own self-contained module, while the simulation engine stays generic. Classic Strategy pattern:

public interface ICountryRulesProvider {

    String getCountryCode();

    String determineIncomeCategory(
        BigDecimal fiscalIncome, int householdSize, String zone);

    List<EligibleSubsidy> calculateSubsidies(
        List<PlannedWork> plannedWorks,
        String incomeCategory,
        String climateZone,
        BigDecimal totalEstimatedCost);
}
Enter fullscreen mode Exit fullscreen mode

Each country implements this. Here's a simplified view of the France provider:

@Service
public class FranceRulesProvider implements ICountryRulesProvider {

    // 2026 income thresholds for zones H1/H2
    // Index: [household_size - 1][very_modest, modest, intermediate]
    private static final int[][] THRESHOLDS_H1_H2 = {
        {17009, 21805, 30549},  // 1 person
        {24875, 32000, 44907},  // 2 persons
        {29917, 38442, 54071},  // 3 persons
        {34948, 44884, 63235},  // 4 persons
        {40002, 51326, 72400}   // 5+ persons
    };

    @Override
    public String getCountryCode() { return "FR"; }

    @Override
    public String determineIncomeCategory(
            BigDecimal fiscalIncome, int householdSize, String zone) {
        int[][] thresholds = "H3".equalsIgnoreCase(zone)
            ? THRESHOLDS_H3 : THRESHOLDS_H1_H2;
        int index = Math.min(householdSize, 5) - 1;
        int[] bracket = thresholds[index];
        int income = fiscalIncome.intValue();

        if (income <= bracket[0]) return "VERY_MODEST";
        if (income <= bracket[1]) return "MODEST";
        if (income <= bracket[2]) return "INTERMEDIATE";
        return "HIGH";
    }

    @Override
    public List<EligibleSubsidy> calculateSubsidies(
            List<PlannedWork> works, String incomeCategory,
            String climateZone, BigDecimal totalCost) {
        List<EligibleSubsidy> subsidies = new ArrayList<>();
        for (PlannedWork work : works) {
            // Per-work schemes: rates loaded from DB
            calculateMaPrimeRenov(work, incomeCategory)
                .ifPresent(subsidies::add);
            calculateCee(work, climateZone)
                .ifPresent(subsidies::add);
        }
        // Cross-work schemes
        calculateEcoPtz(works, totalCost).ifPresent(subsidies::add);
        calculateTvaReduite(totalCost).ifPresent(subsidies::add);
        return subsidies;
    }
}
Enter fullscreen mode Exit fullscreen mode

The registry auto-discovers all providers via Spring DI:

@Service
public class CountryRulesRegistry {

    private final Map<String, ICountryRulesProvider> providers;

    public CountryRulesRegistry(List<ICountryRulesProvider> providerList) {
        this.providers = providerList.stream()
            .collect(Collectors.toMap(
                p -> p.getCountryCode().toUpperCase(),
                p -> p
            ));
        log.info("Initialized with {} providers: {}",
            providers.size(), providers.keySet());
    }

    public ICountryRulesProvider getProvider(String countryCode) {
        ICountryRulesProvider provider = providers.get(
            countryCode.toUpperCase());
        if (provider == null)
            throw new BusinessValidationException(
                "UNSUPPORTED_COUNTRY",
                "Country not supported: " + countryCode);
        return provider;
    }
}
Enter fullscreen mode Exit fullscreen mode

Adding a sixth country? Create a new @Service that implements ICountryRulesProvider. The registry picks it up automatically. Zero changes to the simulation engine.

Normalizing work types across languages

"Isolation des combles" in France is "Dachdammung" in Germany and "Isolamento del tetto" in Italy. If your API accepts free-text work descriptions, you need NLP in five languages. We went with normalized codes instead:

Code                       | FR                        | DE                  | IT
ROOF_INSULATION            | isolation_combles         | dachdaemmung        | isolamento_tetto
HEAT_PUMP_AIR_WATER        | pac_air_eau               | waermepumpe_luft    | pompa_calore_aria
WALL_INSULATION_EXTERIOR   | ite                       | wdvs                | cappotto_termico
WINDOWS_DOUBLE_GLAZING     | fenetres_double_vitrage   | fenster_2fach       | finestre_doppio_vetro
SOLAR_PANELS_PHOTOVOLTAIC  | panneaux_pv               | photovoltaik        | pannelli_fotovoltaici
Enter fullscreen mode Exit fullscreen mode

The API consumer always sends ROOF_INSULATION regardless of which country they're querying. Our mapping table converts it to the local code before looking up rates.

This solved an annoying integration problem: developers who integrate GreenCalc don't need five separate dropdown menus with localized work types. One universal set of 11 codes works everywhere.

Automated regulatory monitoring

The scariest part of running a subsidy API isn't building it — it's keeping it current. When France publishes new MaPrimeRenov thresholds on January 1st, how quickly do you notice?

We built a watcher that hashes the HTML of 10 official government URLs daily:

@Service
@ConditionalOnProperty(
    name = "greencalc.regulatory.enabled",
    havingValue = "true")
public class RegulatoryWatcherService {

    @Scheduled(cron = "${greencalc.regulatory.cron:0 0 6 * * *}")
    public void scheduledCheck() {
        List<RegulatorySource> sources =
            sourceRepository.findByActiveTrue();
        for (RegulatorySource source : sources) {
            HttpResponse<String> response = httpClient.send(
                HttpRequest.newBuilder()
                    .uri(URI.create(source.getSourceUrl()))
                    .GET().build(),
                HttpResponse.BodyHandlers.ofString());

            String newHash = sha256(response.body());
            if (!newHash.equals(source.getLastContentHash())) {
                // Content changed — create alert for review
                createAlert(source, "CONTENT_CHANGED",
                    source.getLastContentHash(), newHash);
            }
            source.setLastContentHash(newHash);
            sourceRepository.save(source);
        }
    }

    static String sha256(String content) {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest(
            content.getBytes(StandardCharsets.UTF_8));
        return HexFormat.of().formatHex(hash);
    }
}
Enter fullscreen mode Exit fullscreen mode

When a hash changes, an alert appears in our admin panel. A human reviews it, updates the rates in the database, and logs the change to a public changelog at GET /api/v1/eligibility/changelog.

It's not perfect — some government sites are SPAs where the hash changes on every load due to CSRF tokens. We're considering diffing the text content instead. But for static pages (which most official subsidy pages are), SHA-256 comparison catches 100% of real changes.

The result: one POST, all subsidies

Here's a real request to the sandbox (no API key needed):

curl -X POST https://greencalc.io/api/v1/eligibility/simulate \
  -H "X-Api-Key: gc_sandbox_000000000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
    "country_code": "FR",
    "household": {
      "annual_income": 25000,
      "household_size": 3,
      "is_owner": true
    },
    "property": {
      "type": "HOUSE",
      "construction_year": 1975,
      "energy_rating": "E",
      "postal_code": "59000",
      "surface_m2": 100
    },
    "planned_works": [
      {"work_type": "ROOF_INSULATION",
       "estimated_cost_eur": 8000, "surface_m2": 80},
      {"work_type": "HEAT_PUMP_AIR_WATER",
       "estimated_cost_eur": 12000}
    ]
  }'
Enter fullscreen mode Exit fullscreen mode

Response (sandbox mode — amounts are illustrative):

{
  "simulation_id": "ef2d5406-7216-4802-853d-116d32494fa4",
  "country": {
    "code": "FR",
    "name": "France",
    "schemes_last_updated_at": "2026-01-01"
  },
  "household_category": "SANDBOX",
  "eligible_subsidies": [
    {
      "scheme_code": "ma_prime_renov",
      "scheme_name": "MaPrimeRenov",
      "provider": "ANAH",
      "type": "GRANT",
      "amount_eur": 5000.0,
      "conditions": [
        "Primary residence",
        "Property at least 15 years old"
      ],
      "cumulative": true
    },
    {
      "scheme_code": "cee",
      "scheme_name": "Certificats d'Economies d'Energie",
      "type": "GRANT",
      "amount_eur": 3200.0
    },
    {
      "scheme_code": "eco_ptz",
      "scheme_name": "Eco-PTZ (zero-interest loan)",
      "type": "LOAN",
      "amount_eur": 20000.0
    },
    {
      "scheme_code": "tva_reduite",
      "scheme_name": "TVA 5.5% (reduced VAT)",
      "type": "VAT_REDUCTION",
      "amount_eur": 2900.0
    }
  ],
  "summary": {
    "total_estimated_cost_eur": 20000.0,
    "total_grants_eur": 8200.0,
    "total_loans_eur": 20000.0,
    "total_tax_savings_eur": 2900.0,
    "estimated_out_of_pocket_eur": 8900.0,
    "estimated_new_energy_rating": "C",
    "estimated_energy_saving_percent": 35.0
  }
}
Enter fullscreen mode Exit fullscreen mode

For a household spending 20,000 EUR on renovations: 8,200 EUR in grants, 20,000 EUR in zero-interest loans, 2,900 EUR in tax savings. Estimated out-of-pocket: 8,900 EUR. All from one API call.

Try it yourself

The sandbox requires no signup — just a fixed API key:

# Swap "FR" for "DE", "IT", "BE", or "ES"
curl -X POST https://greencalc.io/api/v1/eligibility/simulate \
  -H "X-Api-Key: gc_sandbox_000000000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{"country_code":"DE","household":{"annual_income":50000,"household_size":2,"is_owner":true},"property":{"type":"HOUSE","energy_rating":"F","postal_code":"10115","surface_m2":120},"planned_works":[{"work_type":"WALL_INSULATION_EXTERIOR","estimated_cost_eur":35000,"surface_m2":150}]}'
Enter fullscreen mode Exit fullscreen mode

Interactive docs: greencalc.io/docs

Regulatory changelog: greencalc.io/api/v1/eligibility/changelog


GreenCalc is built by AZMORIS Group. The stack is Java 21, Spring Boot 3.3, PostgreSQL 16, and too many government PDFs.

Top comments (0)