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:
| Country | Main schemes | Income‑based? | Climate zones? | Updates |
|---|---|---|---|---|
| France | MaPrimeRenov, CEE, Eco-PTZ, TVA 5.5% | Yes (4 categories) | Yes (H1/H2/H3) | Jan + Jul |
| Germany | KfW 262, BEG EM, BAFA audit | No (flat rates) | No | Variable |
| Italy | Superbonus, Ecobonus, Bonus Casa | No | No | Political |
| Belgium | Primes Wallonie, Renolution, 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: 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:
| country | scheme | work_type | income | rate | max | unit |
|---|---|---|---|---|---|---|
| FR | ma_prime_renov | ROOF_INSULATION | VERY_MODEST | 75.00 | 6000 | PER_M2 |
| FR | ma_prime_renov | ROOF_INSULATION | MODEST | 60.00 | 4800 | PER_M2 |
| FR | cee | ROOF_INSULATION | ALL | 12.00 | — | PER_M2 |
| DE | kfw_beg | HEAT_PUMP | ALL | 25% | 15000 | FIXED |
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.
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 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 |
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
gc_sandbox_000000000000000000000000000000000Swap
"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.