Files
EventHubBack/src/core/core_subscription.erl
T

317 lines
13 KiB
Erlang
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-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([list_subscriptions/0, create_subscription/1, update_subscription/2, delete_subscription/1]).
-export([count_subscription/0]).
-export([count_subscriptions_by_date/2]).
-define(TRIAL_DAYS, 30).
%%%-------------------------------------------------------------------
%%% @doc Создание подписки.
%%% `TrialUsed` `true`, если подписка платная; `false` для пробного периода.
%%% Все поля записи инициализированы, `undefined` не возникает.
%%% @end
%%%-------------------------------------------------------------------
-spec create(UserId :: binary(), Plan :: monthly | quarterly | biannual | annual,
TrialUsed :: boolean()) -> {ok, #subscription{}} | {error, term()}.
create(UserId, Plan, TrialUsed) ->
Id = infra_utils:generate_id(16),
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.
%%%-------------------------------------------------------------------
%%% @doc Получить подписку по идентификатору.
%%% @end
%%%-------------------------------------------------------------------
-spec get_by_id(Id :: binary()) -> {ok, #subscription{}} | {error, not_found}.
get_by_id(Id) ->
case mnesia:dirty_read(subscription, Id) of
[] -> {error, not_found};
[Subscription] -> {ok, Subscription}
end.
%%%-------------------------------------------------------------------
%%% @doc Получить активную подписку пользователя.
%%% @end
%%%-------------------------------------------------------------------
-spec get_active_by_user(UserId :: binary()) -> {ok, #subscription{}} | {error, not_found}.
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.
%%%-------------------------------------------------------------------
%%% @doc Список всех подписок пользователя.
%%% @end
%%%-------------------------------------------------------------------
-spec list_by_user(UserId :: binary()) -> {ok, [#subscription{}]}.
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)}.
%%%-------------------------------------------------------------------
%%% @doc Список всех подписок (для администраторов).
%%% @end
%%%-------------------------------------------------------------------
-spec list_all() -> {ok, [#subscription{}]}.
list_all() ->
Match = #subscription{_ = '_'},
Subscriptions = mnesia:dirty_match_object(Match),
{ok, Subscriptions}.
%%%-------------------------------------------------------------------
%%% @doc Обновить статус подписки.
%%% @end
%%%-------------------------------------------------------------------
-spec update_status(Id :: binary(), Status :: active | expired | cancelled) ->
{ok, #subscription{}} | {error, not_found | term()}.
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.
%%%-------------------------------------------------------------------
%%% @doc Проверка истёкших подписок (вызывается периодически).
%%% @end
%%%-------------------------------------------------------------------
-spec check_expired() -> ok.
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).
%%%-------------------------------------------------------------------
%%% @doc Понижение календарей пользователя до personal при истечении подписки.
%%% @end
%%%-------------------------------------------------------------------
-spec downgrade_user_calendars(UserId :: binary()) -> ok.
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).
%%%-------------------------------------------------------------------
%%% Вспомогательные функции
%%%-------------------------------------------------------------------
-spec plan_to_months(monthly | quarterly | biannual | annual | trial) -> pos_integer().
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.
-spec add_months(calendar:datetime(), pos_integer()) -> calendar:datetime().
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).
-spec add_days(calendar:datetime(), pos_integer()) -> calendar:datetime().
add_days(DateTime, Days) ->
Seconds = calendar:datetime_to_gregorian_seconds(DateTime),
calendar:gregorian_seconds_to_datetime(Seconds + (Days * 86400)).
%%%===================================================================
%%% Новые обёртки для админки
%%%===================================================================
%%%-------------------------------------------------------------------
%%% @doc Список подписок в виде списка записей.
%%% @end
%%%-------------------------------------------------------------------
-spec list_subscriptions() -> [#subscription{}].
list_subscriptions() ->
{ok, Subs} = list_all(),
Subs.
%%%-------------------------------------------------------------------
%%% @doc Создание подписки из map-параметров.
%%% @end
%%%-------------------------------------------------------------------
-spec create_subscription(Data :: map()) -> {ok, #subscription{}} | {error, term()}.
create_subscription(Data) ->
UserId = maps:get(<<"user_id">>, Data),
Plan = case maps:get(<<"plan">>, Data, <<"monthly">>) of
<<"monthly">> -> monthly;
<<"yearly">> -> yearly;
<<"quarterly">> -> quarterly;
<<"biannual">> -> biannual;
<<"annual">> -> annual;
Other -> Other
end,
TrialUsed = maps:get(<<"trial_used">>, Data, false),
create(UserId, Plan, TrialUsed).
%%%-------------------------------------------------------------------
%%% @doc Обновление полей подписки (административная операция).
%%% @end
%%%-------------------------------------------------------------------
-spec update_subscription(Id :: binary(), Updates :: map()) ->
{ok, #subscription{}} | {error, not_found}.
update_subscription(Id, Updates) ->
case get_by_id(Id) of
{ok, Sub} ->
Updated = apply_updates(Sub, Updates),
mnesia:dirty_write(Updated),
{ok, Updated};
Error -> Error
end.
%%%-------------------------------------------------------------------
%%% @doc Удаление подписки (физическое).
%%% @end
%%%-------------------------------------------------------------------
-spec delete_subscription(Id :: binary()) -> {ok, deleted} | {error, not_found}.
delete_subscription(Id) ->
case get_by_id(Id) of
{ok, _Sub} ->
mnesia:dirty_delete({subscription, Id}),
{ok, deleted};
Error -> Error
end.
%%%-------------------------------------------------------------------
%%% @doc Применение обновлений к записи подписки.
%%% @end
%%%-------------------------------------------------------------------
-spec apply_updates(#subscription{}, map()) -> #subscription{}.
apply_updates(Sub, Updates) ->
lists:foldl(fun({Key, Value}, Acc) ->
case Key of
<<"status">> ->
NewStatus = case Value of
<<"active">> -> active;
<<"cancelled">> -> cancelled;
<<"expired">> -> expired;
Other -> Other
end,
Acc#subscription{status = NewStatus, updated_at = calendar:universal_time()};
<<"plan">> ->
NewPlan = case Value of
<<"monthly">> -> monthly;
<<"yearly">> -> yearly;
<<"quarterly">> -> quarterly;
<<"biannual">> -> biannual;
<<"annual">> -> annual;
Other -> Other
end,
Acc#subscription{plan = NewPlan, updated_at = calendar:universal_time()};
<<"trial_used">> ->
Acc#subscription{trial_used = Value, updated_at = calendar:universal_time()};
<<"expires_at">> ->
Acc#subscription{expires_at = parse_iso_datetime(Value), updated_at = calendar:universal_time()};
<<"started_at">> ->
Acc#subscription{started_at = parse_iso_datetime(Value), updated_at = calendar:universal_time()};
_ -> Acc
end
end, Sub, maps:to_list(Updates)).
%%%-------------------------------------------------------------------
%%% @doc Разбор ISO8601 строки в формат `calendar:datetime()`.
%%% @end
%%%-------------------------------------------------------------------
-spec parse_iso_datetime(binary() | term()) -> calendar:datetime() | term().
parse_iso_datetime(Bin) when is_binary(Bin) ->
try
[DateStr, TimeStr] = string:split(Bin, "T"),
TimeStrNoZ = string:trim(TimeStr, trailing, "Z"),
[YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all),
[HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all),
Year = binary_to_integer(list_to_binary(YearStr)),
Month = binary_to_integer(list_to_binary(MonthStr)),
Day = binary_to_integer(list_to_binary(DayStr)),
Hour = binary_to_integer(list_to_binary(HourStr)),
Minute = binary_to_integer(list_to_binary(MinuteStr)),
Second = binary_to_integer(list_to_binary(SecondStr)),
{{Year, Month, Day}, {Hour, Minute, Second}}
catch _:_ -> Bin
end;
parse_iso_datetime(Other) -> Other.
%%%-------------------------------------------------------------------
%%% @doc Количество подписок.
%%% @end
%%%-------------------------------------------------------------------
-spec count_subscription() -> non_neg_integer().
count_subscription() ->
mnesia:table_info(subscription, size).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт подписок, созданных в заданном временном диапазоне.
%%% @end
%%%-------------------------------------------------------------------
-spec count_subscriptions_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_subscriptions_by_date(From, To) ->
All = mnesia:dirty_match_object(#subscription{_ = '_'}),
Filtered = lists:filter(fun(S) -> S#subscription.created_at >= From andalso
S#subscription.created_at =< To end, All),
Counts = lists:foldl(fun(S, Acc) ->
Day = date_part(S#subscription.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts).
date_part({{Y,M,D}, _}) -> {Y,M,D}.