We have come to simpler (in our understanding) solution to this problem - take the least day number from original and add_month result dates, as this:
TRUNC(ADD_MONTHS(input_date,1),'MM') + LEAST(TO_CHAR(input_date, 'DD'), TO_CHAR(ADD_MONTHS(input_date,1), 'DD')) - 1
Some other examples here do not work on every date, below our test results:
WITH DATES as (
SELECT TO_DATE('2020-01-31', 'YYYY-MM-DD HH24:MI:SS') as input_date,
'2020-02-29' as expected_date
FROM dual
UNION ALL
SELECT TO_DATE('2020-02-28', 'YYYY-MM-DD HH24:MI:SS'),
'2020-03-28'
FROM dual
UNION ALL
SELECT TO_DATE('2020-09-30', 'YYYY-MM-DD HH24:MI:SS'),
'2020-10-30'
FROM dual
UNION ALL
SELECT TO_DATE('2020-09-01', 'YYYY-MM-DD HH24:MI:SS'),
'2020-10-01'
FROM dual
UNION ALL
SELECT TO_DATE('2019-01-30', 'YYYY-MM-DD HH24:MI:SS'),
'2019-02-28'
FROM dual
UNION ALL
SELECT TO_DATE('2020-02-29', 'YYYY-MM-DD HH24:MI:SS'),
'2020-03-29'
FROM dual
UNION ALL
SELECT TO_DATE('2020-09-29', 'YYYY-MM-DD HH24:MI:SS'),
'2020-10-29'
FROM dual
UNION ALL
SELECT TO_DATE('2020-03-01', 'YYYY-MM-DD HH24:MI:SS'),
'2020-04-01'
FROM dual
),
methods as (
SELECT
input_date,
expected_date,
ADD_MONTHS(input_date,1) as standard_way,
add_months(input_date-1, 1)+1 as wrong_way,
TO_DATE(LEAST(TO_CHAR(input_date, 'DD'), TO_CHAR(ADD_MONTHS(input_date,1), 'DD')) || '-' || TO_CHAR(ADD_MONTHS(input_date,1), 'MM-YYYY'), 'DD-MM-YYYY') as good_way,
TRUNC(ADD_MONTHS(input_date,1),'MM') + LEAST(TO_CHAR(input_date, 'DD'), TO_CHAR(ADD_MONTHS(input_date,1), 'DD')) - 1 as better_way
FROM
DATES
)
SELECT
input_date,
expected_date,
standard_way,
CASE WHEN TO_CHAR(standard_way,'YYYY-MM-DD') = expected_date THEN 'OK' ELSE 'NOK' END as standard_way_ok,
wrong_way,
CASE WHEN TO_CHAR(wrong_way,'YYYY-MM-DD') = expected_date THEN 'OK' ELSE 'NOK' END as wrong_way_ok,
good_way,
CASE WHEN TO_CHAR(good_way,'YYYY-MM-DD') = expected_date THEN 'OK' ELSE 'NOK' END as good_way_ok,
better_way,
CASE WHEN TO_CHAR(better_way,'YYYY-MM-DD') = expected_date THEN 'OK' ELSE 'NOK' END as better_way_ok
FROM
methods
;