diff --git a/src/core/core_calendar.erl b/src/core/core_calendar.erl index f1a07ac..3bf938b 100644 --- a/src/core/core_calendar.erl +++ b/src/core/core_calendar.erl @@ -1,7 +1,7 @@ -module(core_calendar). -include("records.hrl"). --export([create/4, get_by_id/1, list_by_owner/1, update/2, delete/1]). +-export([create/4, create/5, get_by_id/1, list_by_owner/1, update/2, delete/1]). -export([generate_id/0]). %% Создание календаря @@ -32,6 +32,34 @@ create(OwnerId, Title, Description, Confirmation) -> {aborted, Reason} -> {error, Reason} end. +%% Создание календаря с типом и политикой +create(OwnerId, Title, Description, Confirmation, Type) -> + Id = generate_id(), + Calendar = #calendar{ + id = Id, + owner_id = OwnerId, + title = Title, + description = Description, + tags = [], + type = Type, + confirmation = Confirmation, + rating_avg = 0.0, + rating_count = 0, + status = active, + created_at = calendar:universal_time(), + updated_at = calendar:universal_time() + }, + + F = fun() -> + mnesia:write(Calendar), + {ok, Calendar} + end, + + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + %% Получение календаря по ID get_by_id(Id) -> case mnesia:dirty_read(calendar, Id) of diff --git a/src/core/core_subscription.erl b/src/core/core_subscription.erl new file mode 100644 index 0000000..301e3c7 --- /dev/null +++ b/src/core/core_subscription.erl @@ -0,0 +1,143 @@ +-module(core_subscription). +-include("records.hrl"). + +-export([create/3, get_by_id/1, get_active_by_user/1, list_by_user/1, list_all/0]). +-export([update_status/2, check_expired/0]). +-export([generate_id/0]). + +-define(TRIAL_DAYS, 30). + +%% Создание подписки +create(UserId, Plan, TrialUsed) -> + Id = generate_id(), + Now = calendar:universal_time(), + + {StartDate, EndDate} = case TrialUsed of + true -> + % Платная подписка + DurationMonths = plan_to_months(Plan), + End = add_months(Now, DurationMonths), + {Now, End}; + false -> + % Пробный период + End = add_days(Now, ?TRIAL_DAYS), + {Now, End} + end, + + Subscription = #subscription{ + id = Id, + user_id = UserId, + plan = Plan, + status = active, + trial_used = TrialUsed, + started_at = StartDate, + expires_at = EndDate, + created_at = Now, + updated_at = Now + }, + + F = fun() -> + mnesia:write(Subscription), + {ok, Subscription} + end, + + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + +%% Получение подписки по ID +get_by_id(Id) -> + case mnesia:dirty_read(subscription, Id) of + [] -> {error, not_found}; + [Subscription] -> {ok, Subscription} + end. + +%% Получение активной подписки пользователя +get_active_by_user(UserId) -> + Match = #subscription{user_id = UserId, status = active, _ = '_'}, + case mnesia:dirty_match_object(Match) of + [] -> {error, not_found}; + [Subscription] -> {ok, Subscription} + end. + +%% Список всех подписок пользователя +list_by_user(UserId) -> + Match = #subscription{user_id = UserId, _ = '_'}, + Subscriptions = mnesia:dirty_match_object(Match), + {ok, lists:sort(fun(A, B) -> A#subscription.created_at >= B#subscription.created_at end, Subscriptions)}. + +%% Список всех подписок (для админов) +list_all() -> + Match = #subscription{_ = '_'}, + Subscriptions = mnesia:dirty_match_object(Match), + {ok, Subscriptions}. + +%% Обновление статуса подписки +update_status(Id, Status) when Status =:= active; Status =:= expired; Status =:= cancelled -> + F = fun() -> + case mnesia:read(subscription, Id) of + [] -> + {error, not_found}; + [Subscription] -> + Updated = Subscription#subscription{ + status = Status, + updated_at = calendar:universal_time() + }, + mnesia:write(Updated), + {ok, Updated} + end + end, + + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + +%% Проверка истёкших подписок (вызывается периодически) +check_expired() -> + Now = calendar:universal_time(), + Match = #subscription{status = active, _ = '_'}, + ActiveSubscriptions = mnesia:dirty_match_object(Match), + + lists:foreach(fun(Sub) -> + case Sub#subscription.expires_at < Now of + true -> + update_status(Sub#subscription.id, expired), + % Если у пользователя нет другой активной подписки, понижаем календари + case get_active_by_user(Sub#subscription.user_id) of + {error, not_found} -> + downgrade_user_calendars(Sub#subscription.user_id); + _ -> ok + end; + false -> ok + end + end, ActiveSubscriptions). + +%% Понижение календарей пользователя до personal при истечении подписки +downgrade_user_calendars(UserId) -> + Match = #calendar{owner_id = UserId, type = commercial, _ = '_'}, + Calendars = mnesia:dirty_match_object(Match), + lists:foreach(fun(Cal) -> + core_calendar:update(Cal#calendar.id, [{type, personal}]) + end, Calendars). + +%% Внутренние функции +generate_id() -> + base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). + +plan_to_months(monthly) -> 1; +plan_to_months(quarterly) -> 3; +plan_to_months(biannual) -> 6; +plan_to_months(annual) -> 12; +plan_to_months(trial) -> 1. % пробный период на 1 месяц + +add_months(DateTime, Months) -> + Seconds = calendar:datetime_to_gregorian_seconds(DateTime), + Days = Seconds div 86400, + NewDays = Days + (Months * 30), + calendar:gregorian_seconds_to_datetime(NewDays * 86400). + +add_days(DateTime, Days) -> + Seconds = calendar:datetime_to_gregorian_seconds(DateTime), + calendar:gregorian_seconds_to_datetime(Seconds + (Days * 86400)). \ No newline at end of file diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index de47911..6c54b0c 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -45,6 +45,7 @@ start_http() -> {"/v1/reviews/:id", handler_review_by_id, []}, {"/v1/reports", handler_reports, []}, {"/v1/tickets", handler_tickets, []}, + {"/v1/subscription", handler_subscription, []}, % Админские маршруты - более конкретные ПЕРЕД общими {"/v1/admin/reports", handler_reports, []}, @@ -55,6 +56,8 @@ start_http() -> {"/v1/admin/tickets/stats", handler_ticket_stats, []}, {"/v1/admin/tickets/:id", handler_ticket_by_id, []}, {"/v1/admin/tickets", handler_tickets, []}, + {"/v1/admin/subscriptions", handler_admin_subscriptions, []}, + {"/v1/admin/subscriptions/:id", handler_admin_subscriptions, []}, % Общий маршрут для заморозки (должен быть последним) {"/v1/admin/:target_type/:id", handler_admin_moderation, []} diff --git a/src/handlers/handler_admin_subscriptions.erl b/src/handlers/handler_admin_subscriptions.erl new file mode 100644 index 0000000..87a1250 --- /dev/null +++ b/src/handlers/handler_admin_subscriptions.erl @@ -0,0 +1,82 @@ +-module(handler_admin_subscriptions). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> list_subscriptions(Req); + <<"DELETE">> -> cancel_subscription(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +%% GET /v1/admin/subscriptions - список всех подписок +list_subscriptions(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case is_admin(AdminId) of + true -> + {ok, Subscriptions} = core_subscription:list_all(), + Response = [subscription_to_json(S) || S <- Subscriptions], + send_json(Req1, 200, Response); + false -> + send_error(Req1, 403, <<"Admin access required">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% DELETE /v1/admin/subscriptions/:id - отменить подписку +cancel_subscription(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + SubscriptionId = cowboy_req:binding(id, Req1), + case logic_subscription:cancel_subscription(AdminId, SubscriptionId) of + {ok, Subscription} -> + Response = subscription_to_json(Subscription), + send_json(Req1, 200, Response); + {error, access_denied} -> + send_error(Req1, 403, <<"Admin access required">>); + {error, not_found} -> + send_error(Req1, 404, <<"Subscription not found">>); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% Вспомогательные функции +is_admin(UserId) -> + case core_user:get_by_id(UserId) of + {ok, User} -> User#user.role =:= admin; + _ -> false + end. + +subscription_to_json(Subscription) -> + #{ + id => Subscription#subscription.id, + user_id => Subscription#subscription.user_id, + plan => Subscription#subscription.plan, + status => Subscription#subscription.status, + trial_used => Subscription#subscription.trial_used, + started_at => datetime_to_iso8601(Subscription#subscription.started_at), + expires_at => datetime_to_iso8601(Subscription#subscription.expires_at), + created_at => datetime_to_iso8601(Subscription#subscription.created_at), + updated_at => datetime_to_iso8601(Subscription#subscription.updated_at) + }. + +datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> + iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", + [Year, Month, Day, Hour, Minute, Second])). + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/handlers/handler_calendars.erl b/src/handlers/handler_calendars.erl index 7fca924..1479ee4 100644 --- a/src/handlers/handler_calendars.erl +++ b/src/handlers/handler_calendars.erl @@ -27,6 +27,18 @@ create_calendar(Req) -> Tags = maps:get(<<"tags">>, Decoded, []), Type = parse_type(maps:get(<<"type">>, Decoded, <<"personal">>)), + % Проверяем подписку для commercial календарей ДО создания + case Type of + commercial -> + case logic_subscription:can_create_commercial_calendar(UserId) of + true -> ok; + false -> + send_error(Req2, 402, <<"Subscription required for commercial calendar">>), + throw(stop) + end; + personal -> ok + end, + case logic_calendar:create_calendar(UserId, Title, Description, Confirmation) of {ok, Calendar} -> % Обновляем теги и тип @@ -46,6 +58,7 @@ create_calendar(Req) -> _ -> send_error(Req2, 400, <<"Invalid JSON">>) catch + throw:stop -> ok; % Уже отправили ошибку _:_ -> send_error(Req2, 400, <<"Invalid JSON format">>) end; diff --git a/src/handlers/handler_subscription.erl b/src/handlers/handler_subscription.erl new file mode 100644 index 0000000..45466da --- /dev/null +++ b/src/handlers/handler_subscription.erl @@ -0,0 +1,102 @@ +-module(handler_subscription). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_subscription(Req); + <<"POST">> -> create_subscription(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +%% GET /v1/subscription - получить подписку текущего пользователя +get_subscription(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + case logic_subscription:get_user_subscription(UserId) of + {ok, Subscription} -> + send_json(Req1, 200, Subscription); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% POST /v1/subscription - активировать подписку +create_subscription(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + Decoded when is_map(Decoded) -> + case Decoded of + #{<<"action">> := <<"start_trial">>} -> + case logic_subscription:start_trial(UserId) of + {ok, Subscription} -> + Response = subscription_to_json(Subscription), + send_json(Req2, 201, Response); + {error, already_has_subscription} -> + send_error(Req2, 409, <<"Already has active subscription">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + #{<<"action">> := <<"activate">>, <<"plan">> := PlanBin} -> + Plan = parse_plan(PlanBin), + PaymentInfo = maps:get(<<"payment_info">>, Decoded, #{}), + case logic_subscription:activate_subscription(UserId, Plan, PaymentInfo) of + {ok, Subscription} -> + Response = subscription_to_json(Subscription), + send_json(Req2, 201, Response); + {error, payment_failed} -> + send_error(Req2, 402, <<"Payment failed">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req2, 400, <<"Invalid action. Use 'start_trial' or 'activate'">>) + end; + _ -> + send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> + send_error(Req2, 400, <<"Invalid JSON format">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% Вспомогательные функции +parse_plan(<<"monthly">>) -> monthly; +parse_plan(<<"quarterly">>) -> quarterly; +parse_plan(<<"biannual">>) -> biannual; +parse_plan(<<"annual">>) -> annual; +parse_plan(_) -> monthly. + +subscription_to_json(Subscription) when is_tuple(Subscription) -> + #{ + id => Subscription#subscription.id, + plan => Subscription#subscription.plan, + status => Subscription#subscription.status, + trial_used => Subscription#subscription.trial_used, + started_at => datetime_to_iso8601(Subscription#subscription.started_at), + expires_at => datetime_to_iso8601(Subscription#subscription.expires_at) + }; +subscription_to_json(Subscription) when is_map(Subscription) -> + Subscription. + +datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> + iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", + [Year, Month, Day, Hour, Minute, Second])). + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/logic/logic_calendar.erl b/src/logic/logic_calendar.erl index b6d1767..44c00ad 100644 --- a/src/logic/logic_calendar.erl +++ b/src/logic/logic_calendar.erl @@ -11,11 +11,24 @@ create_calendar(UserId, Title, Description) -> %% Создание календаря с указанной политикой подтверждения create_calendar(UserId, Title, Description, Confirmation) -> + Type = personal, % По умолчанию + create_calendar(UserId, Title, Description, Confirmation, Type). + +create_calendar(UserId, Title, Description, Confirmation, Type) -> case core_user:get_by_id(UserId) of {ok, User} -> case User#user.status of active -> - core_calendar:create(UserId, Title, Description, Confirmation); + % Проверяем подписку для commercial календарей + case Type of + commercial -> + case logic_subscription:can_create_commercial_calendar(UserId) of + true -> ok; + false -> {error, subscription_required} + end; + personal -> ok + end, + core_calendar:create(UserId, Title, Description, Confirmation, Type); _ -> {error, user_inactive} end; diff --git a/src/logic/logic_subscription.erl b/src/logic/logic_subscription.erl new file mode 100644 index 0000000..83f0ef2 --- /dev/null +++ b/src/logic/logic_subscription.erl @@ -0,0 +1,168 @@ +-module(logic_subscription). +-include("records.hrl"). + +-export([start_trial/1, activate_subscription/3, cancel_subscription/2]). +-export([get_user_subscription/1, get_subscription/2, list_subscriptions/1]). +-export([check_user_subscription/1, can_create_commercial_calendar/1]). +-export([handle_expired_subscriptions/0]). + +-define(TRIAL_DAYS, 30). + +%% ============ Управление подписками ============ + +%% Начать пробный период (вызывается при первой попытке создать commercial календарь) +start_trial(UserId) -> + case core_subscription:get_active_by_user(UserId) of + {ok, _} -> + {error, already_has_subscription}; + {error, not_found} -> + % Проверяем, не использовал ли пользователь уже пробный период + {ok, AllSubs} = core_subscription:list_by_user(UserId), + case lists:any(fun(S) -> S#subscription.trial_used == true orelse S#subscription.plan == trial end, AllSubs) of + true -> + {error, trial_already_used}; + false -> + case core_subscription:create(UserId, trial, true) of + {ok, Subscription} -> + {ok, Subscription}; + Error -> Error + end + end + end. + +%% Активировать платную подписку +activate_subscription(UserId, Plan, PaymentInfo) -> + case process_payment(PaymentInfo, plan_price(Plan)) of + ok -> + % Проверяем, была ли у пользователя хоть одна подписка + {ok, AllSubs} = core_subscription:list_by_user(UserId), + TrialUsed = length(AllSubs) > 0, + + % Деактивируем старые подписки + case core_subscription:get_active_by_user(UserId) of + {ok, Active} -> + core_subscription:update_status(Active#subscription.id, expired); + _ -> ok + end, + + % Создаём новую подписку + case core_subscription:create(UserId, Plan, TrialUsed) of + {ok, Subscription} -> + {ok, Subscription}; + Error -> Error + end; + {error, payment_failed} -> + {error, payment_failed} + end. + +%% Отменить подписку +cancel_subscription(AdminId, SubscriptionId) -> + case is_admin(AdminId) of + true -> + case core_subscription:get_by_id(SubscriptionId) of + {ok, Subscription} -> + core_subscription:update_status(SubscriptionId, cancelled); + Error -> Error + end; + false -> {error, access_denied} + end. + +%% ============ Получение информации ============ + +%% Получить активную подписку пользователя +get_user_subscription(UserId) -> + case core_subscription:get_active_by_user(UserId) of + {ok, Subscription} -> + {ok, format_subscription(Subscription)}; + {error, not_found} -> + {ok, #{status => free}} + end. + +%% Получить подписку по ID (только админ) +get_subscription(AdminId, SubscriptionId) -> + case is_admin(AdminId) of + true -> core_subscription:get_by_id(SubscriptionId); + false -> {error, access_denied} + end. + +%% Список подписок пользователя +list_subscriptions(UserId) -> + core_subscription:list_by_user(UserId). + +%% ============ Проверки ============ + +%% Проверить статус подписки пользователя +check_user_subscription(UserId) -> + case core_subscription:get_active_by_user(UserId) of + {ok, Sub} -> + Now = calendar:universal_time(), + case Sub#subscription.expires_at > Now of + true -> {ok, active, Sub#subscription.plan}; + false -> {ok, expired, free} + end; + {error, not_found} -> + {ok, free, free} + end. + +%% Проверить, может ли пользователь создавать коммерческие календари +%% Если у пользователя нет активной подписки, но он ещё не использовал пробный период, +%% автоматически запускаем пробный период +can_create_commercial_calendar(UserId) -> + case check_user_subscription(UserId) of + {ok, active, _} -> + true; + {ok, free, free} -> + % Пользователь без подписки - проверяем, не использовал ли он уже пробный период + {ok, AllSubs} = core_subscription:list_by_user(UserId), + TrialUsed = lists:any(fun(S) -> S#subscription.trial_used == true orelse S#subscription.plan == trial end, AllSubs), + case TrialUsed of + false -> + % Автоматически запускаем пробный период + case start_trial(UserId) of + {ok, _} -> true; + _ -> false + end; + true -> + false + end; + _ -> + false + end. + +%% ============ Обслуживание ============ + +%% Обработать истёкшие подписки (вызывается периодически) +handle_expired_subscriptions() -> + core_subscription:check_expired(). + +%% ============ Внутренние функции ============ + +is_admin(UserId) -> + case core_user:get_by_id(UserId) of + {ok, User} -> User#user.role =:= admin; + _ -> false + end. + +plan_price(monthly) -> 999; % $9.99 +plan_price(quarterly) -> 2499; % $24.99 +plan_price(biannual) -> 4499; % $44.99 +plan_price(annual) -> 7999; % $79.99 +plan_price(trial) -> 0. + +process_payment(_PaymentInfo, _Amount) -> + % Заглушка платёжного шлюза - всегда успешно + ok. + +format_subscription(Subscription) -> + #{ + id => Subscription#subscription.id, + plan => Subscription#subscription.plan, + status => Subscription#subscription.status, + trial_used => Subscription#subscription.trial_used, + started_at => datetime_to_iso8601(Subscription#subscription.started_at), + expires_at => datetime_to_iso8601(Subscription#subscription.expires_at) + }. + +datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> + iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", + [Year, Month, Day, Hour, Minute, Second])). \ No newline at end of file diff --git a/test/core_subscription_tests.erl b/test/core_subscription_tests.erl new file mode 100644 index 0000000..0262254 --- /dev/null +++ b/test/core_subscription_tests.erl @@ -0,0 +1,90 @@ +-module(core_subscription_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + mnesia:start(), + mnesia:create_table(subscription, [ + {attributes, record_info(fields, subscription)}, + {ram_copies, [node()]} + ]), + mnesia:create_table(calendar, [ + {attributes, record_info(fields, calendar)}, + {ram_copies, [node()]} + ]), + ok. + +cleanup(_) -> + mnesia:delete_table(calendar), + mnesia:delete_table(subscription), + mnesia:stop(), + ok. + +core_subscription_test_() -> + {foreach, + fun setup/0, + fun cleanup/1, + [ + {"Create trial subscription", fun test_create_trial/0}, + {"Create paid subscription", fun test_create_paid/0}, + {"Get active by user", fun test_get_active_by_user/0}, + {"List by user", fun test_list_by_user/0}, + {"Update status", fun test_update_status/0}, + {"Check expired", fun test_check_expired/0} + ]}. + +test_create_trial() -> + UserId = <<"user123">>, + {ok, Sub} = core_subscription:create(UserId, trial, false), + + ?assertEqual(UserId, Sub#subscription.user_id), + ?assertEqual(trial, Sub#subscription.plan), + ?assertEqual(false, Sub#subscription.trial_used), + ?assertEqual(active, Sub#subscription.status). + +test_create_paid() -> + UserId = <<"user123">>, + {ok, Sub} = core_subscription:create(UserId, monthly, true), + + ?assertEqual(UserId, Sub#subscription.user_id), + ?assertEqual(monthly, Sub#subscription.plan), + ?assertEqual(true, Sub#subscription.trial_used), + ?assertEqual(active, Sub#subscription.status). + +test_get_active_by_user() -> + UserId = <<"user123">>, + {ok, Sub1} = core_subscription:create(UserId, trial, false), + {ok, Sub2} = core_subscription:create(UserId, monthly, true), + + core_subscription:update_status(Sub1#subscription.id, expired), + + {ok, Active} = core_subscription:get_active_by_user(UserId), + ?assertEqual(Sub2#subscription.id, Active#subscription.id). + +test_list_by_user() -> + UserId = <<"user123">>, + {ok, _} = core_subscription:create(UserId, trial, false), + {ok, _} = core_subscription:create(UserId, monthly, true), + + {ok, Subs} = core_subscription:list_by_user(UserId), + ?assertEqual(2, length(Subs)). + +test_update_status() -> + UserId = <<"user123">>, + {ok, Sub} = core_subscription:create(UserId, trial, false), + + {ok, Cancelled} = core_subscription:update_status(Sub#subscription.id, cancelled), + ?assertEqual(cancelled, Cancelled#subscription.status). + +test_check_expired() -> + UserId = <<"user123">>, + % Создаём подписку с истёкшим сроком + {ok, Sub} = core_subscription:create(UserId, trial, false), + % Принудительно устанавливаем expires_at в прошлое + Past = {{2020, 1, 1}, {0, 0, 0}}, + mnesia:dirty_write(Sub#subscription{expires_at = Past}), + + core_subscription:check_expired(), + + {ok, Updated} = core_subscription:get_by_id(Sub#subscription.id), + ?assertEqual(expired, Updated#subscription.status). \ No newline at end of file diff --git a/test/logic_subscription_tests.erl b/test/logic_subscription_tests.erl new file mode 100644 index 0000000..c097137 --- /dev/null +++ b/test/logic_subscription_tests.erl @@ -0,0 +1,120 @@ +-module(logic_subscription_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +logic_subscription_test_() -> + {foreach, + fun setup/0, + fun cleanup/1, + [ + {"Start trial", fun test_start_trial/0}, + {"Start trial duplicate", fun test_start_trial_duplicate/0}, + {"Activate subscription (no trial)", fun test_activate_subscription_no_trial/0}, + {"Activate subscription (after trial)", fun test_activate_subscription_after_trial/0}, + {"Check subscription - free", fun test_check_free/0}, + {"Check subscription - trial", fun test_check_trial/0}, + {"Check subscription - paid", fun test_check_paid/0}, + {"Can create commercial - free (auto-trial)", fun test_can_create_commercial_free/0}, + {"Can create commercial - trial", fun test_can_create_commercial_trial/0}, + {"Can create commercial - paid", fun test_can_create_commercial_paid/0}, + {"Get user subscription - free", fun test_get_user_subscription_free/0}, + {"Get user subscription - trial", fun test_get_user_subscription_trial/0}, + {"Get user subscription - paid", fun test_get_user_subscription_paid/0} + ]}. + +setup() -> + mnesia:start(), + mnesia:create_table(user, [{attributes, record_info(fields, user)}, {ram_copies, [node()]}]), + mnesia:create_table(calendar, [{attributes, record_info(fields, calendar)}, {ram_copies, [node()]}]), + mnesia:create_table(subscription, [{attributes, record_info(fields, subscription)}, {ram_copies, [node()]}]), + ok. + +cleanup(_) -> + mnesia:delete_table(subscription), + mnesia:delete_table(calendar), + mnesia:delete_table(user), + mnesia:stop(), + ok. + +create_test_user() -> + UserId = base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}), + User = #user{ + id = UserId, + email = <>, + password_hash = <<"hash">>, + role = user, + status = active, + created_at = calendar:universal_time(), + updated_at = calendar:universal_time() + }, + mnesia:dirty_write(User), + UserId. + +%% ============ Тесты ============ + +test_start_trial() -> + UserId = create_test_user(), + {ok, Sub} = logic_subscription:start_trial(UserId), + ?assertEqual(trial, Sub#subscription.plan), + ?assertEqual(true, Sub#subscription.trial_used). + +test_start_trial_duplicate() -> + UserId = create_test_user(), + {ok, _} = logic_subscription:start_trial(UserId), + {error, already_has_subscription} = logic_subscription:start_trial(UserId). + +test_activate_subscription_no_trial() -> + UserId = create_test_user(), + {ok, Sub} = logic_subscription:activate_subscription(UserId, monthly, #{card => "4242"}), + ?assertEqual(monthly, Sub#subscription.plan), + ?assertEqual(false, Sub#subscription.trial_used). + +test_activate_subscription_after_trial() -> + UserId = create_test_user(), + {ok, _} = logic_subscription:start_trial(UserId), + {ok, Sub} = logic_subscription:activate_subscription(UserId, monthly, #{card => "4242"}), + ?assertEqual(monthly, Sub#subscription.plan), + ?assertEqual(true, Sub#subscription.trial_used). + +test_check_free() -> + UserId = create_test_user(), + {ok, free, free} = logic_subscription:check_user_subscription(UserId). + +test_check_trial() -> + UserId = create_test_user(), + {ok, _} = logic_subscription:start_trial(UserId), + {ok, active, trial} = logic_subscription:check_user_subscription(UserId). + +test_check_paid() -> + UserId = create_test_user(), + {ok, _} = logic_subscription:activate_subscription(UserId, monthly, #{card => "4242"}), + {ok, active, monthly} = logic_subscription:check_user_subscription(UserId). + +test_can_create_commercial_free() -> + % Новый пользователь автоматически получает пробный период при проверке + UserId = create_test_user(), + ?assert(logic_subscription:can_create_commercial_calendar(UserId)). + +test_can_create_commercial_trial() -> + UserId = create_test_user(), + {ok, _} = logic_subscription:start_trial(UserId), + ?assert(logic_subscription:can_create_commercial_calendar(UserId)). + +test_can_create_commercial_paid() -> + UserId = create_test_user(), + {ok, _} = logic_subscription:activate_subscription(UserId, monthly, #{card => "4242"}), + ?assert(logic_subscription:can_create_commercial_calendar(UserId)). + +test_get_user_subscription_free() -> + UserId = create_test_user(), + {ok, #{status := free}} = logic_subscription:get_user_subscription(UserId). + +test_get_user_subscription_trial() -> + UserId = create_test_user(), + {ok, _} = logic_subscription:start_trial(UserId), + {ok, #{status := active, plan := trial}} = logic_subscription:get_user_subscription(UserId). + +test_get_user_subscription_paid() -> + UserId = create_test_user(), + {ok, _} = logic_subscription:activate_subscription(UserId, annual, #{card => "4242"}), + {ok, #{status := active, plan := annual}} = logic_subscription:get_user_subscription(UserId). \ No newline at end of file diff --git a/test/scripts/test_subscription_api.sh b/test/scripts/test_subscription_api.sh new file mode 100644 index 0000000..5463254 --- /dev/null +++ b/test/scripts/test_subscription_api.sh @@ -0,0 +1,217 @@ +#!/bin/bash + +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' + +BASE_URL="http://localhost:8080" + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +extract_json() { + echo "$1" | grep -o "\"$2\":\"[^\"]*\"" | head -1 | sed "s/\"$2\":\"//;s/\"$//" +} + +http_post() { + local url=$1; local data=$2; local token=$3 + if [ -n "$token" ]; then + curl -s -X POST "$url" -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d "$data" + else + curl -s -X POST "$url" -H "Content-Type: application/json" -d "$data" + fi +} + +http_get() { + local url=$1; local token=$2 + if [ -n "$token" ]; then + curl -s -X GET "$url" -H "Authorization: Bearer $token" + else + curl -s -X GET "$url" + fi +} + +echo "============================================================" +echo " EVENTHUB SUBSCRIPTION API TEST SCRIPT" +echo "============================================================" +echo "" + +log_info "Checking if server is running..." +if ! curl -s "$BASE_URL/health" | grep -q "ok"; then + log_error "Server is not running" + exit 1 +fi +log_success "Server is running" + +echo "" +log_info "============================================================" +log_info "STEP 1: Create test users" +log_info "============================================================" + +# Пользователь 1 (будет использовать пробный период через commercial календарь) +USER1_EMAIL="sub_user1_$(date +%s)@example.com" +USER1_PASSWORD="user123" + +log_info "Creating user 1..." +response=$(http_post "$BASE_URL/v1/register" "{\"email\":\"$USER1_EMAIL\",\"password\":\"$USER1_PASSWORD\"}" "") +USER1_TOKEN=$(extract_json "$response" "token") +USER1_ID=$(extract_json "$response" "id") +log_success "User 1 created" + +# Пользователь 2 (для проверки, что пробный период используется один раз) +USER2_EMAIL="sub_user2_$(date +%s)@example.com" +USER2_PASSWORD="user123" + +log_info "Creating user 2..." +response=$(http_post "$BASE_URL/v1/register" "{\"email\":\"$USER2_EMAIL\",\"password\":\"$USER2_PASSWORD\"}" "") +USER2_TOKEN=$(extract_json "$response" "token") +USER2_ID=$(extract_json "$response" "id") +log_success "User 2 created" + +echo "" +log_info "============================================================" +log_info "TEST 1: Get subscription (free)" +log_info "============================================================" + +response=$(http_get "$BASE_URL/v1/subscription" "$USER1_TOKEN") +if echo "$response" | grep -q "free"; then + log_success "User has free subscription" +else + log_error "Expected free subscription: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 2: Create personal calendar (should work with free)" +log_info "============================================================" + +response=$(http_post "$BASE_URL/v1/calendars" "{\"title\":\"Personal Calendar\",\"type\":\"personal\"}" "$USER1_TOKEN") +PERSONAL_CALENDAR_ID=$(extract_json "$response" "id") + +if [ -n "$PERSONAL_CALENDAR_ID" ]; then + log_success "Personal calendar created (free subscription allows this)" +else + log_error "Failed to create personal calendar: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 3: Create commercial calendar (auto-activates trial)" +log_info "============================================================" + +log_info "User 1 creating commercial calendar (should auto-start trial)..." +response=$(http_post "$BASE_URL/v1/calendars" "{\"title\":\"Commercial Calendar\",\"type\":\"commercial\"}" "$USER1_TOKEN") +COMMERCIAL_CALENDAR_ID=$(extract_json "$response" "id") + +if [ -n "$COMMERCIAL_CALENDAR_ID" ]; then + log_success "Commercial calendar created - trial auto-activated" +else + log_error "Failed to create commercial calendar: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 4: Get subscription (should be trial now)" +log_info "============================================================" + +response=$(http_get "$BASE_URL/v1/subscription" "$USER1_TOKEN") +if echo "$response" | grep -q "trial"; then + log_success "User now has trial subscription" +else + log_error "Expected trial subscription: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 5: Create second commercial calendar (should still work)" +log_info "============================================================" + +response=$(http_post "$BASE_URL/v1/calendars" "{\"title\":\"Second Commercial\",\"type\":\"commercial\"}" "$USER1_TOKEN") +SECOND_COMMERCIAL_ID=$(extract_json "$response" "id") + +if [ -n "$SECOND_COMMERCIAL_ID" ]; then + log_success "Second commercial calendar created" +else + log_error "Failed to create second commercial calendar: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 6: User 2 creates commercial calendar (gets trial)" +log_info "============================================================" + +log_info "User 2 creating commercial calendar..." +response=$(http_post "$BASE_URL/v1/calendars" "{\"title\":\"User2 Commercial\",\"type\":\"commercial\"}" "$USER2_TOKEN") +USER2_CALENDAR_ID=$(extract_json "$response" "id") + +if [ -n "$USER2_CALENDAR_ID" ]; then + log_success "User 2 commercial calendar created - trial activated" +else + log_error "User 2 failed: $response" +fi + +response=$(http_get "$BASE_URL/v1/subscription" "$USER2_TOKEN") +if echo "$response" | grep -q "trial"; then + log_success "User 2 has trial subscription" +else + log_error "User 2 subscription: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 7: Activate paid subscription" +log_info "============================================================" + +log_info "User 1 activating paid subscription..." +response=$(http_post "$BASE_URL/v1/subscription" "{\"action\":\"activate\",\"plan\":\"monthly\",\"payment_info\":{\"card\":\"4242424242424242\"}}" "$USER1_TOKEN") +if echo "$response" | grep -q "monthly"; then + log_success "Paid subscription activated" +else + log_error "Failed to activate: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 8: Get subscription (should be active paid)" +log_info "============================================================" + +response=$(http_get "$BASE_URL/v1/subscription" "$USER1_TOKEN") +if echo "$response" | grep -q "active" && echo "$response" | grep -q "monthly"; then + log_success "User has active paid subscription" +else + log_error "Expected active paid subscription: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 9: User 3 (free) tries to use already consumed trial" +log_info "============================================================" + +# Создаём пользователя 3, который сначала использует trial, потом отменяет подписку +USER3_EMAIL="sub_user3_$(date +%s)@example.com" +USER3_PASSWORD="user123" + +log_info "Creating user 3..." +response=$(http_post "$BASE_URL/v1/register" "{\"email\":\"$USER3_EMAIL\",\"password\":\"$USER3_PASSWORD\"}" "") +USER3_TOKEN=$(extract_json "$response" "token") +log_success "User 3 created" + +log_info "User 3 creating commercial calendar (uses trial)..." +response=$(http_post "$BASE_URL/v1/calendars" "{\"title\":\"User3 Commercial\",\"type\":\"commercial\"}" "$USER3_TOKEN") +USER3_CALENDAR_ID=$(extract_json "$response" "id") +log_success "Commercial calendar created" + +log_info "Simulating trial expiration (requires admin or time travel - skipped)" + +echo "" +echo "============================================================" +log_success "SUBSCRIPTION API TESTS COMPLETED!" +echo "============================================================" +echo "" +echo "Summary:" +echo " User 1: $USER1_EMAIL (trial -> paid)" +echo " User 2: $USER2_EMAIL (trial)" +echo " User 3: $USER3_EMAIL (trial)" +echo "" \ No newline at end of file