Repository navigation
feat(kromer): implement subscriptions #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
qrmcat
wants to merge
4
commits into
ReconnectedCC:master
Choose a base branch
from
qrmcat:master
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
a40081c
feat(kromer): implement subscriptions
qrmcat 2460ed4
fix(kromer): refine subscription billing and cancellation
qrmcat bde1144
feat(kromer): add subscription caps, allowlists, and closing, and fix…
qrmcat e77b2c8
feat(kromer): support multi wallet subscription queries :3
qrmcat File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| using Kromer.Models.Api.V1; | ||
| using Kromer.Models.Api.V1.Subscriptions; | ||
| using Kromer.Models.Dto; | ||
| using Kromer.Models.Exceptions; | ||
| using Kromer.Repositories; | ||
| using Microsoft.AspNetCore.Mvc; | ||
|
|
||
| namespace Kromer.Controllers.V1; | ||
|
|
||
| [Route("api/v1/subscriptions")] | ||
| [ApiController] | ||
| public class SubscriptionsController(SubscriptionRepository subscriptionRepository) : ControllerBase | ||
| { | ||
| /// <summary> | ||
| /// Creates a subscription contract for the supplied name or metaname. | ||
| /// </summary> | ||
| /// <param name="request">The contract details and private key of the current name owner.</param> | ||
| /// <returns>The identifier of the created subscription contract.</returns> | ||
| /// <exception cref="KromerException">Thrown when the request is invalid, authentication fails, or the name does not exist.</exception> | ||
| [HttpPost("")] | ||
| public async Task<ActionResult<Result<CreateSubscriptionResponse>>> CreateSubscription( | ||
| [FromBody] CreateSubscriptionRequest? request) | ||
| { | ||
| return new Result<CreateSubscriptionResponse>( | ||
| await subscriptionRepository.CreateContractAsync(request)); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Cancels a subscription contract and all active wallet subscriptions attached to it. | ||
| /// </summary> | ||
| /// <param name="id">The identifier of the subscription contract to cancel.</param> | ||
| /// <param name="request">The private key of the current name owner.</param> | ||
| /// <returns>The cancelled subscription contract.</returns> | ||
| /// <exception cref="KromerException">Thrown when the contract is not found, the private key is invalid, or the caller does not own the name.</exception> | ||
| [HttpDelete("{id:int}")] | ||
|
bananasov marked this conversation as resolved.
|
||
| public async Task<ActionResult<Result<SubscriptionDto>>> DeleteSubscription(int id, | ||
| [FromBody] PrivateKeyRequest? request) | ||
| { | ||
| return new Result<SubscriptionDto>( | ||
| await subscriptionRepository.CancelContractAsync(id, request?.PrivateKey)); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Closes a subscription contract to new subscribers while keeping existing subscriptions active. | ||
| /// </summary> | ||
| /// <param name="id">The identifier of the subscription contract to close.</param> | ||
| /// <param name="request">The private key of the current name owner.</param> | ||
| /// <returns>The closed subscription contract.</returns> | ||
| /// <exception cref="KromerException">Thrown when the contract is not found, the private key is invalid, or the caller does not own the name.</exception> | ||
| [HttpPost("{id:int}/close")] | ||
|
bananasov marked this conversation as resolved.
|
||
| public async Task<ActionResult<Result<SubscriptionDto>>> CloseSubscription(int id, | ||
| [FromBody] PrivateKeyRequest? request) | ||
| { | ||
| return new Result<SubscriptionDto>( | ||
| await subscriptionRepository.CloseContractAsync(id, request?.PrivateKey)); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Retrieves a subscription contract by its identifier. | ||
| /// </summary> | ||
| /// <param name="id">The identifier of the subscription contract to retrieve.</param> | ||
| /// <param name="addresses">Optional wallet addresses used to include caller-specific subscription state.</param> | ||
| /// <returns>The subscription contract details.</returns> | ||
| /// <exception cref="KromerException">Thrown when the contract is not found or the address is invalid.</exception> | ||
| [HttpGet("{id:int}")] | ||
|
bananasov marked this conversation as resolved.
|
||
| public async Task<ActionResult<Result<SubscriptionDto>>> GetSubscription(int id, | ||
| [FromQuery(Name = "address")] List<string>? addresses = null) | ||
| { | ||
| return new Result<SubscriptionDto>( | ||
| await subscriptionRepository.GetContractAsync(id, addresses)); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Subscribes the authenticated wallet to a subscription contract. | ||
| /// </summary> | ||
| /// <param name="id">The identifier of the subscription contract to subscribe to.</param> | ||
| /// <param name="request">The private key of the subscribing wallet.</param> | ||
| /// <returns>The next payment date for the wallet subscription.</returns> | ||
| /// <exception cref="KromerException">Thrown when authentication fails or the wallet cannot subscribe to the contract.</exception> | ||
| [HttpPost("{id:int}/subscribe")] | ||
|
bananasov marked this conversation as resolved.
|
||
| public async Task<ActionResult<Result<SubscribeResponse>>> Subscribe(int id, [FromBody] PrivateKeyRequest? request) | ||
| { | ||
| return new Result<SubscribeResponse>( | ||
| await subscriptionRepository.SubscribeAsync(id, request?.PrivateKey)); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Unsubscribes the authenticated wallet from a subscription contract. | ||
| /// </summary> | ||
| /// <param name="id">The identifier of the subscription contract to unsubscribe from.</param> | ||
| /// <param name="request">The private key of the subscribed wallet.</param> | ||
| /// <returns>An empty result when the unsubscribe request has been processed.</returns> | ||
| /// <exception cref="KromerException">Thrown when authentication fails, the contract is not found, or the subscription cannot be unsubscribed.</exception> | ||
| [HttpPost("{id:int}/unsubscribe")] | ||
|
bananasov marked this conversation as resolved.
|
||
| public async Task<ActionResult<Result<object>>> Unsubscribe(int id, [FromBody] PrivateKeyRequest? request) | ||
| { | ||
| await subscriptionRepository.UnsubscribeAsync(id, request?.PrivateKey); | ||
| return new Result<object>(new { }); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Lists subscription contracts related to a wallet address or name. | ||
| /// </summary> | ||
| /// <param name="addresses">The wallet addresses used to find owned and subscribed contracts.</param> | ||
| /// <param name="names">The names or metanames used to filter contracts by receiver.</param> | ||
| /// <param name="excludeOwned">Excludes contracts owned by the supplied addresses when set.</param> | ||
| /// <param name="onlyOwned">Only includes contracts owned by the supplied addresses when set.</param> | ||
| /// <param name="onlyUnsubscribable">Only includes active wallet subscriptions that can currently be unsubscribed.</param> | ||
| /// <param name="limit">The maximum number of contracts to return. The value is between 1 and 1000!</param> | ||
| /// <param name="offset">The number of contracts to skip before returning results.</param> | ||
| /// <returns>A paginated list of subscription contracts.</returns> | ||
| /// <exception cref="KromerException">Thrown when neither address nor name is supplied, or when supplied filters are invalid.</exception> | ||
| [HttpGet("")] | ||
|
bananasov marked this conversation as resolved.
|
||
| public async Task<ActionResult<Result<SubscriptionListResponse>>> ListSubscriptions( | ||
| [FromQuery(Name = "address")] List<string>? addresses = null, | ||
| [FromQuery(Name = "name")] List<string>? names = null, | ||
| [FromQuery(Name = "exclude_owned")] bool excludeOwned = false, | ||
| [FromQuery(Name = "only_owned")] bool onlyOwned = false, | ||
| [FromQuery(Name = "only_unsubscribable")] bool onlyUnsubscribable = true, | ||
| [FromQuery] int limit = 50, | ||
| [FromQuery] int offset = 0) | ||
| { | ||
| return new Result<SubscriptionListResponse>( | ||
| await subscriptionRepository.ListContractsAsync(addresses, names, excludeOwned, onlyOwned, | ||
| onlyUnsubscribable, limit, offset)); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| using Kromer.Data; | ||
| using Kromer.Models.Entities; | ||
| using Microsoft.EntityFrameworkCore.Infrastructure; | ||
| using Microsoft.EntityFrameworkCore.Migrations; | ||
| using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||
|
|
||
| #nullable disable | ||
|
|
||
| namespace Kromer.Migrations; | ||
|
|
||
| [DbContext(typeof(KromerContext))] | ||
| [Migration("20260427000000_AddSubscriptions")] | ||
| public partial class AddSubscriptions : Migration | ||
| { | ||
| protected override void Up(MigrationBuilder migrationBuilder) | ||
| { | ||
| migrationBuilder.Sql(""" | ||
| DO $$ | ||
| BEGIN | ||
| IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'subscription_status') THEN | ||
| CREATE TYPE public.subscription_status AS ENUM ('active', 'closed', 'cancelled'); | ||
| ELSIF NOT EXISTS ( | ||
| SELECT 1 | ||
| FROM pg_enum e | ||
| JOIN pg_type t ON t.oid = e.enumtypid | ||
| WHERE t.typname = 'subscription_status' AND e.enumlabel = 'closed' | ||
| ) THEN | ||
| ALTER TYPE public.subscription_status ADD VALUE 'closed' BEFORE 'cancelled'; | ||
| END IF; | ||
| END | ||
| $$; | ||
| """); | ||
|
|
||
| migrationBuilder.CreateTable( | ||
| name: "subscription_contracts", | ||
| columns: table => new | ||
| { | ||
| id = table.Column<int>(type: "integer", nullable: false) | ||
| .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), | ||
| receiver = table.Column<string>(type: "character varying(98)", maxLength: 98, nullable: false), | ||
| base_name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false), | ||
| price = table.Column<decimal>(type: "numeric(16,5)", precision: 16, scale: 5, nullable: false), | ||
| period_minutes = table.Column<int>(type: "integer", nullable: false), | ||
| description = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false), | ||
| max_subscribers = table.Column<int>(type: "integer", nullable: true), | ||
| allowed_subscriber_addresses = table.Column<string[]>(type: "text[]", nullable: true), | ||
| status = table.Column<SubscriptionStatus>(type: "public.subscription_status", nullable: false), | ||
| created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), | ||
| cancelled_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true) | ||
| }, | ||
| constraints: table => | ||
| { | ||
| table.PrimaryKey("subscription_contracts_pkey", x => x.id); | ||
| }); | ||
|
|
||
| migrationBuilder.CreateTable( | ||
| name: "wallet_subscriptions", | ||
| columns: table => new | ||
| { | ||
| id = table.Column<int>(type: "integer", nullable: false) | ||
| .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), | ||
| contract_id = table.Column<int>(type: "integer", nullable: false), | ||
| wallet_address = table.Column<string>(type: "character(10)", fixedLength: true, maxLength: 10, nullable: false), | ||
| next_payment = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), | ||
| status = table.Column<SubscriptionStatus>(type: "public.subscription_status", nullable: false), | ||
| cancellation_reason = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true), | ||
| can_unsubscribe = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true), | ||
| created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), | ||
| cancelled_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true) | ||
| }, | ||
| constraints: table => | ||
| { | ||
| table.PrimaryKey("wallet_subscriptions_pkey", x => x.id); | ||
| table.ForeignKey( | ||
| name: "wallet_subscriptions_contract_id_fkey", | ||
| column: x => x.contract_id, | ||
| principalTable: "subscription_contracts", | ||
| principalColumn: "id", | ||
| onDelete: ReferentialAction.Cascade); | ||
| }); | ||
|
|
||
| migrationBuilder.CreateIndex( | ||
| name: "idx_subscription_contract_base_name", | ||
| table: "subscription_contracts", | ||
| column: "base_name"); | ||
|
|
||
| migrationBuilder.CreateIndex( | ||
| name: "idx_subscription_contract_receiver", | ||
| table: "subscription_contracts", | ||
| column: "receiver"); | ||
|
|
||
| migrationBuilder.CreateIndex( | ||
| name: "idx_subscription_contract_status", | ||
| table: "subscription_contracts", | ||
| column: "status"); | ||
|
|
||
| migrationBuilder.CreateIndex( | ||
| name: "idx_wallet_subscription_contract_id", | ||
| table: "wallet_subscriptions", | ||
| column: "contract_id"); | ||
|
|
||
| migrationBuilder.CreateIndex( | ||
| name: "idx_wallet_subscription_next_payment", | ||
| table: "wallet_subscriptions", | ||
| column: "next_payment"); | ||
|
|
||
| migrationBuilder.CreateIndex( | ||
| name: "idx_wallet_subscription_wallet_address", | ||
| table: "wallet_subscriptions", | ||
| column: "wallet_address"); | ||
|
|
||
| migrationBuilder.CreateIndex( | ||
| name: "unique_active_wallet_subscription", | ||
| table: "wallet_subscriptions", | ||
| columns: new[] { "contract_id", "wallet_address" }, | ||
| unique: true, | ||
| filter: "status = 'active'"); | ||
| } | ||
|
|
||
| protected override void Down(MigrationBuilder migrationBuilder) | ||
| { | ||
| migrationBuilder.DropTable( | ||
| name: "wallet_subscriptions"); | ||
|
|
||
| migrationBuilder.DropTable( | ||
| name: "subscription_contracts"); | ||
|
|
||
| migrationBuilder.Sql("DROP TYPE IF EXISTS public.subscription_status;"); | ||
| } | ||
| } |
21 changes: 21 additions & 0 deletions
21
Kromer/Models/Api/V1/Subscriptions/CreateSubscriptionRequest.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| using System.Text.Json.Serialization; | ||
|
|
||
| namespace Kromer.Models.Api.V1.Subscriptions; | ||
|
|
||
| public class CreateSubscriptionRequest | ||
| { | ||
| [JsonPropertyName("privatekey")] | ||
| public required string PrivateKey { get; set; } | ||
|
|
||
| public required string Name { get; set; } | ||
|
|
||
| public decimal Price { get; set; } | ||
|
|
||
| public int Period { get; set; } | ||
|
|
||
| public required string Description { get; set; } | ||
|
|
||
| public int? MaxSubscribers { get; set; } | ||
|
|
||
| public List<string>? AllowedSubscribers { get; set; } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.