March 29, 2026 · GreenCalc Team · 8 min read

How we mapped EU renovation subsidies into one JSON response

Building an API that calculates renovation subsidy eligibility across 5 EU countries — architecture decisions, regulatory chaos, and the Strategy pattern in practice.

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:

CountryMain schemesIncome‑based?Climate zones?Updates
FranceMaPrimeRenov, CEE, Eco-PTZ, TVA 5.5%Yes (4 categories)Yes (H1/H2/H3)Jan + Jul
GermanyKfW 262, BEG EM, BAFA auditNo (flat rates)NoVariable
ItalySuperbonus, Ecobonus, Bonus CasaNoNoPolitical
BelgiumPrimes Wallonie, Renolution, Mijn VerbouwPremieYes (per region)NoJanuary
SpainPREE, MOVES IIIPartiallyNoVariable

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: 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';

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 looks like this:

countryschemework_typeincomeratemaxunit
FRma_prime_renovROOF_INSULATIONVERY_MODEST75.006000PER_M2
FRma_prime_renovROOF_INSULATIONMODEST60.004800PER_M2
FRceeROOF_INSULATIONALL12.00PER_M2
DEkfw_begHEAT_PUMPALL25%15000FIXED

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.

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);
}

The registry auto-discovers all providers via Spring DI — no manual registration:

@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));
    }

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

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

Pattern takeaway: If you have N strategies that share the same interface and you want auto-discovery, inject a List<YourInterface> in Spring and build a lookup map in the constructor. Clean, extensible, zero registration boilerplate.

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:

Normalized codeFRDEIT
ROOF_INSULATIONisolation_comblesdachdaemmungisolamento_tetto
HEAT_PUMP_AIR_WATERpac_air_eauwaermepumpe_luftpompa_calore_aria
WALL_INSULATION_EXTERIORitewdvscappotto_termico
WINDOWS_DOUBLE_GLAZINGfenetres_double_vitragefenster_2fachfinestre_doppio_vetro
SOLAR_PANELS_PHOTOVOLTAICpanneaux_pvphotovoltaikpannelli_fotovoltaici

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. Developers don’t need five separate localized dropdown menus — 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 = "0 0 6 * * *") // 06:00 UTC daily
    public void scheduledCheck() {
        for (RegulatorySource source : sourceRepository.findByActiveTrue()) {
            String newHash = sha256(fetch(source.getSourceUrl()));
            if (!newHash.equals(source.getLastContentHash())) {
                // Content changed! Create alert for human review
                createAlert(source, "CONTENT_CHANGED",
                    source.getLastContentHash(), newHash);
            }
            source.setLastContentHash(newHash);
        }
    }

    static String sha256(String content) {
        return HexFormat.of().formatHex(
            MessageDigest.getInstance("SHA-256")
                .digest(content.getBytes(UTF_8)));
    }
}

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

The result: one POST, all subsidies

Here’s a real request to the sandbox (free, no signup):

curl -X POST https://greencalc.io/api/v1/eligibility/simulate \
  -H "X-Api-Key: gc_sandbox_000...000" \
  -H "Content-Type: application/json" \
  -d '{
    "country_code": "FR",
    "household": {
      "annual_income": 25000,
      "household_size": 3,
      "is_owner": true
    },
    "property": {
      "type": "HOUSE",
      "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}
    ]
  }'

Response (sandbox — amounts are illustrative):

{
  "eligible_subsidies": [
    {"scheme_name": "MaPrimeRenov",
     "type": "GRANT", "amount_eur": 5000.0},
    {"scheme_name": "CEE",
     "type": "GRANT", "amount_eur": 3200.0},
    {"scheme_name": "Eco-PTZ",
     "type": "LOAN", "amount_eur": 20000.0},
    {"scheme_name": "TVA 5.5%",
     "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
  }
}

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

Try it yourself

The sandbox requires no signup — just use the fixed API key: gc_sandbox_000000000000000000000000000000000

Swap "FR" for "DE", "IT", "BE", or "ES" to test other countries.

Interactive API docs (Swagger UI) · Regulatory changelog · Supported countries

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