diff --git a/src/main/kotlin/g3701_3800/s3716_find_churn_risk_customers/readme.md b/src/main/kotlin/g3701_3800/s3716_find_churn_risk_customers/readme.md new file mode 100644 index 00000000..1898d18f --- /dev/null +++ b/src/main/kotlin/g3701_3800/s3716_find_churn_risk_customers/readme.md @@ -0,0 +1,104 @@ +3716\. Find Churn Risk Customers + +Medium + +Table: `subscription_events` + + +------------------+---------+ + | Column Name | Type | + +------------------+---------+ + | event_id | int | + | user_id | int | + | event_date | date | + | event_type | varchar | + | plan_name | varchar | + | monthly_amount | decimal | + +------------------+---------+ + event_id is the unique identifier for this table. + event_type can be start, upgrade, downgrade, or cancel. + plan_name can be basic, standard, premium, or NULL (when event_type is cancel). + monthly_amount represents the monthly subscription cost after this event. + For cancel events, monthly_amount is 0. + +Write a solution to **Find Churn Risk Customers** - users who show warning signs before churning. A user is considered **churn risk customer** if they meet ALL the following criteria: + +* Currently have an **active subscription** (their last event is not cancel). +* Have performed **at least one** downgrade in their subscription history. +* Their **current plan revenue** is less than `50%` of their historical maximum plan revenue. +* Have been a subscriber for **at least** `60` days. + +Return _the result table ordered by_ `days_as_subscriber` _in **descending** order, then by_ `user_id` _in **ascending** order_. + +The result format is in the following example. + +**Example:** + +**Input:** + +subscription\_events table: + + +----------+---------+------------+------------+-----------+----------------+ + | event_id | user_id | event_date | event_type | plan_name | monthly_amount | + +----------+---------+------------+------------+-----------+----------------+ + | 1 | 501 | 2024-01-01 | start | premium | 29.99 | + | 2 | 501 | 2024-02-15 | downgrade | standard | 19.99 | + | 3 | 501 | 2024-03-20 | downgrade | basic | 9.99 | + | 4 | 502 | 2024-01-05 | start | standard | 19.99 | + | 5 | 502 | 2024-02-10 | upgrade | premium | 29.99 | + | 6 | 502 | 2024-03-15 | downgrade | basic | 9.99 | + | 7 | 503 | 2024-01-10 | start | basic | 9.99 | + | 8 | 503 | 2024-02-20 | upgrade | standard | 19.99 | + | 9 | 503 | 2024-03-25 | upgrade | premium | 29.99 | + | 10 | 504 | 2024-01-15 | start | premium | 29.99 | + | 11 | 504 | 2024-03-01 | downgrade | standard | 19.99 | + | 12 | 504 | 2024-03-30 | cancel | NULL | 0.00 | + | 13 | 505 | 2024-02-01 | start | basic | 9.99 | + | 14 | 505 | 2024-02-28 | upgrade | standard | 19.99 | + | 15 | 506 | 2024-01-20 | start | premium | 29.99 | + | 16 | 506 | 2024-03-10 | downgrade | basic | 9.99 | + +----------+---------+------------+------------+-----------+----------------+ + +**Output:** + + +---------+--------------+------------------------+-----------------------+--------------------+ + | user_id | current_plan | current_monthly_amount | max_historical_amount | days_as_subscriber | + +---------+--------------+------------------------+-----------------------+--------------------+ + | 501 | basic | 9.99 | 29.99 | 79 | + | 502 | basic | 9.99 | 29.99 | 69 | + +---------+--------------+------------------------+-----------------------+--------------------+ + +**Explanation:** + +* **User 501**: + * Currently active: Last event is downgrade to basic (not cancelled) + * Has downgrades: Yes, 2 downgrades in history + * Current revenue (9.99) vs max (29.99): 9.99/29.99 = 33.3% (less than 50%) + * Days as subscriber: Jan 1 to Mar 20 = 79 days (at least 60) + * Result: **Churn Risk Customer** +* **User 502**: + * Currently active: Last event is downgrade to basic (not cancelled) + * Has downgrades: Yes, 1 downgrade in history + * Current revenue (9.99) vs max (29.99): 9.99/29.99 = 33.3% (less than 50%) + * Days as subscriber: Jan 5 to Mar 15 = 70 days (at least 60) + * Result: **Churn Risk Customer** +* **User 503**: + * Currently active: Last event is upgrade to premium (not cancelled) + * Has downgrades: No downgrades in history + * Result: **Not at-risk** (no downgrade history) +* **User 504**: + * Currently active: Last event is cancel + * Result: **Not at-risk** (subscription cancelled) +* **User 505**: + * Currently active: Last event is 'upgrade' to standard (not cancelled) + * Has downgrades: No downgrades in history + * Result: **Not at-risk** (no downgrade history) +* **User 506**: + * Currently active: Last event is downgrade to basic (not cancelled) + * Has downgrades: Yes, 1 downgrade in history + * Current revenue (9.99) vs max (29.99): 9.99/29.99 = 33.3% (less than 50%) + * Days as subscriber: Jan 20 to Mar 10 = 50 days (less than 60) + * Result: **Not at-risk** (insufficient subscription duration) + +Result table is ordered by days\_as\_subscriber DESC, then user\_id ASC. + +**Note:** days\_as\_subscriber is calculated from the first event date to the last event date for each user. \ No newline at end of file diff --git a/src/main/kotlin/g3701_3800/s3716_find_churn_risk_customers/script.sql b/src/main/kotlin/g3701_3800/s3716_find_churn_risk_customers/script.sql new file mode 100644 index 00000000..cff84fe2 --- /dev/null +++ b/src/main/kotlin/g3701_3800/s3716_find_churn_risk_customers/script.sql @@ -0,0 +1,29 @@ +# Write your MySQL query statement below +# #Medium #Database #2025_10_16_Time_256_ms_(96.87%)_Space_0.0_MB_(100.00%) +WITH UserStats AS ( + SELECT + user_id, + MIN(CASE WHEN event_type = 'start' THEN event_date END) AS start_date, + MAX(event_date) AS last_event, + MAX(monthly_amount) AS max_revenue, + SUM(CASE WHEN event_type = 'downgrade' THEN 1 ELSE 0 END) AS downgrade_count + FROM subscription_events + GROUP BY user_id +) +SELECT + us.user_id, + se.plan_name AS current_plan, + se.monthly_amount AS current_monthly_amount, + us.max_revenue AS max_historical_amount, + TIMESTAMPDIFF(DAY, us.start_date, us.last_event) AS days_as_subscriber +FROM UserStats us +JOIN subscription_events se + ON us.user_id = se.user_id + AND us.last_event = se.event_date +WHERE se.event_type <> 'cancel' + AND us.downgrade_count > 0 + AND se.monthly_amount * 2 < us.max_revenue + AND TIMESTAMPDIFF(DAY, us.start_date, us.last_event) > 60 +ORDER BY + TIMESTAMPDIFF(DAY, us.start_date, us.last_event) DESC, + us.user_id ASC; diff --git a/src/test/kotlin/g3701_3800/s3716_find_churn_risk_customers/MysqlTest.kt b/src/test/kotlin/g3701_3800/s3716_find_churn_risk_customers/MysqlTest.kt new file mode 100644 index 00000000..0eb365df --- /dev/null +++ b/src/test/kotlin/g3701_3800/s3716_find_churn_risk_customers/MysqlTest.kt @@ -0,0 +1,87 @@ +package g3701_3800.s3716_find_churn_risk_customers + +import org.hamcrest.CoreMatchers +import org.hamcrest.MatcherAssert +import org.junit.jupiter.api.Test +import org.zapodot.junit.db.annotations.EmbeddedDatabase +import org.zapodot.junit.db.annotations.EmbeddedDatabaseTest +import org.zapodot.junit.db.common.CompatibilityMode +import java.io.BufferedReader +import java.io.FileNotFoundException +import java.io.FileReader +import java.sql.SQLException +import java.util.stream.Collectors +import javax.sql.DataSource + +@EmbeddedDatabaseTest( + compatibilityMode = CompatibilityMode.MySQL, + initialSqls = [ + ( + "CREATE TABLE subscription_events (" + + " event_id INTEGER PRIMARY KEY," + + " user_id INTEGER NOT NULL," + + " event_date DATE NOT NULL," + + " event_type VARCHAR(20) NOT NULL," + + " plan_name VARCHAR(20)," + + " monthly_amount DECIMAL(10,2) NOT NULL" + + ");" + + "INSERT INTO subscription_events (event_id, user_id, event_date, " + + "event_type, plan_name, monthly_amount) VALUES" + + "(1, 501, '2024-01-01', 'start', 'premium', 29.99)," + + "(2, 501, '2024-02-15', 'downgrade', 'standard', 19.99)," + + "(3, 501, '2024-03-20', 'downgrade', 'basic', 9.99)," + + "(4, 502, '2024-01-05', 'start', 'standard', 19.99)," + + "(5, 502, '2024-02-10', 'upgrade', 'premium', 29.99)," + + "(6, 502, '2024-03-15', 'downgrade', 'basic', 9.99)," + + "(7, 503, '2024-01-10', 'start', 'basic', 9.99)," + + "(8, 503, '2024-02-20', 'upgrade', 'standard', 19.99)," + + "(9, 503, '2024-03-25', 'upgrade', 'premium', 29.99)," + + "(10, 504, '2024-01-15', 'start', 'premium', 29.99)," + + "(11, 504, '2024-03-01', 'downgrade','standard', 19.99)," + + "(12, 504, '2024-03-30', 'cancel', NULL, 0.00)," + + "(13, 505, '2024-02-01', 'start', 'basic', 9.99)," + + "(14, 505, '2024-02-28', 'upgrade', 'standard', 19.99)," + + "(15, 506, '2024-01-20', 'start', 'premium', 29.99)," + + "(16, 506, '2024-03-10', 'downgrade','basic', 9.99);" + + "" + ), + ], +) +internal class MysqlTest { + @Test + @Throws(SQLException::class, FileNotFoundException::class) + fun testScript(@EmbeddedDatabase dataSource: DataSource) { + dataSource.connection.use { connection -> + connection.createStatement().use { statement -> + statement.executeQuery( + BufferedReader( + FileReader( + ( + "src/main/kotlin/g3701_3800/" + + "s3716_find_churn_risk_customers/" + + "script.sql" + ), + ), + ) + .lines() + .collect(Collectors.joining("\n")) + .replace("#.*?\\r?\\n".toRegex(), ""), + ).use { resultSet -> + MatcherAssert.assertThat(resultSet.next(), CoreMatchers.equalTo(true)) + MatcherAssert.assertThat(resultSet.getString(1), CoreMatchers.equalTo("501")) + MatcherAssert.assertThat(resultSet.getString(2), CoreMatchers.equalTo("basic")) + MatcherAssert.assertThat(resultSet.getString(3), CoreMatchers.equalTo("9.99")) + MatcherAssert.assertThat(resultSet.getString(4), CoreMatchers.equalTo("29.99")) + MatcherAssert.assertThat(resultSet.getString(5), CoreMatchers.equalTo("79")) + MatcherAssert.assertThat(resultSet.next(), CoreMatchers.equalTo(true)) + MatcherAssert.assertThat(resultSet.getString(1), CoreMatchers.equalTo("502")) + MatcherAssert.assertThat(resultSet.getString(2), CoreMatchers.equalTo("basic")) + MatcherAssert.assertThat(resultSet.getString(3), CoreMatchers.equalTo("9.99")) + MatcherAssert.assertThat(resultSet.getString(4), CoreMatchers.equalTo("29.99")) + MatcherAssert.assertThat(resultSet.getString(5), CoreMatchers.equalTo("70")) + MatcherAssert.assertThat(resultSet.next(), CoreMatchers.equalTo(false)) + } + } + } + } +}