diff --git a/app/migrations/versions/0c5d68f9e4b1_add_user_customers_and_product_slug.py b/app/migrations/versions/0c5d68f9e4b1_add_user_customers_and_product_slug.py deleted file mode 100644 index a5919f3..0000000 --- a/app/migrations/versions/0c5d68f9e4b1_add_user_customers_and_product_slug.py +++ /dev/null @@ -1,85 +0,0 @@ -"""add user customers and product slug - -Revision ID: 0c5d68f9e4b1 -Revises: a4b841bf907c -Create Date: 2026-04-07 11:40:00.000000 -""" - -from typing import Sequence, Union -from uuid import uuid4 - -from alembic import op -import sqlalchemy as sa - - -revision: str = "0c5d68f9e4b1" -down_revision: Union[str, None] = "a4b841bf907c" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.add_column("products", sa.Column("slug", sa.String(), nullable=True)) - op.create_index(op.f("ix_products_slug"), "products", ["slug"], unique=True) - - op.create_table( - "user_customers", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("user_uuid", sa.UUID(), nullable=False), - sa.Column("customer_uuid", sa.UUID(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["customer_uuid"], ["customers.uuid"]), - sa.ForeignKeyConstraint(["user_uuid"], ["users.uuid"]), - sa.PrimaryKeyConstraint("uuid"), - ) - op.create_index( - "ix_user_customers_user_customer", - "user_customers", - ["user_uuid", "customer_uuid"], - unique=True, - ) - - connection = op.get_bind() - user_rows = connection.execute( - sa.text("SELECT uuid, customer_uuid FROM users WHERE customer_uuid IS NOT NULL") - ).fetchall() - for row in user_rows: - connection.execute( - sa.text( - """ - INSERT INTO user_customers (uuid, user_uuid, customer_uuid, created_at) - VALUES (:uuid, :user_uuid, :customer_uuid, now()) - ON CONFLICT DO NOTHING - """ - ), - { - "uuid": str(uuid4()), - "user_uuid": str(row.uuid), - "customer_uuid": str(row.customer_uuid), - }, - ) - - op.execute( - """ - UPDATE products - SET slug = lower(regexp_replace(name, '[^a-zA-Z0-9]+', '-', 'g')) - WHERE slug IS NULL - """ - ) - op.alter_column("customer_products", "end_date", existing_type=sa.DateTime(), nullable=True) - - op.execute( - """ - UPDATE products - SET slug = trim(both '-' from slug) - WHERE slug IS NOT NULL - """ - ) - - -def downgrade() -> None: - op.alter_column("customer_products", "end_date", existing_type=sa.DateTime(), nullable=False) - op.drop_index("ix_user_customers_user_customer", table_name="user_customers") - op.drop_table("user_customers") - op.drop_index(op.f("ix_products_slug"), table_name="products") - op.drop_column("products", "slug") diff --git a/app/migrations/versions/a4b841bf907c_initialize_models.py b/app/migrations/versions/a4b841bf907c_initialize_models.py deleted file mode 100644 index 11bba66..0000000 --- a/app/migrations/versions/a4b841bf907c_initialize_models.py +++ /dev/null @@ -1,274 +0,0 @@ -"""initialize models - -Revision ID: a4b841bf907c -Revises: -Create Date: 2025-04-22 15:23:17.189505 - -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = "a4b841bf907c" -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "contracts", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column( - "key", - sa.Enum( - "AGB_APP_ZUM_DOC_PATIENT", - "AGB_MEDIQUU_CONNECT", - "AGB_APP_ZUM_DOC", - "AGB_MEDIQUU_NETZMANAGER", - "AGB_MEDIQUU_CHAT", - "PRIVACY_CONCEPT", - "PRIVACY_CONCEPT_TASKS", - "AVV", - "AVV_TASKS", - "NDA", - "SUB", - "TOM", - name="contractkeyenum", - ), - nullable=False, - ), - sa.Column("version", sa.String(), nullable=False), - sa.Column("url", sa.String(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint("uuid"), - ) - op.create_table( - "customers", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("email", sa.String(), nullable=False), - sa.Column("website_url", sa.String(), nullable=True), - sa.Column("phone_number", sa.String(), nullable=True), - sa.Column("address", sa.String(), nullable=False), - sa.Column("house_number", sa.String(), nullable=False), - sa.Column("care_of", sa.String(), nullable=True), - sa.Column("postal_code", sa.String(), nullable=False), - sa.Column("city", sa.String(), nullable=False), - sa.Column("country", sa.String(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint("uuid"), - sa.UniqueConstraint("email"), - ) - op.create_table( - "products", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("image_url", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint("uuid"), - ) - op.create_table( - "messages", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("customer_uuid", sa.UUID(), nullable=False), - sa.Column("subject", sa.String(), nullable=False), - sa.Column("message", sa.Text(), nullable=False), - sa.Column( - "status", - sa.Enum("UNREAD", "READ", "ARCHIVED", name="messagestatusenum"), - nullable=False, - ), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint( - ["customer_uuid"], - ["customers.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - ) - op.create_table( - "product_contracts", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("product_uuid", sa.UUID(), nullable=False), - sa.Column("contract_uuid", sa.UUID(), nullable=False), - sa.ForeignKeyConstraint( - ["contract_uuid"], - ["contracts.uuid"], - ), - sa.ForeignKeyConstraint( - ["product_uuid"], - ["products.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - ) - op.create_table( - "product_plans", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("product_uuid", sa.UUID(), nullable=False), - sa.Column( - "type", - sa.Enum("ONCE", "RECURRING", "LIFETIME", "TRIAL", name="plantypeenum"), - nullable=False, - ), - sa.Column("name", sa.String(), nullable=False), - sa.Column("cost_euro", sa.Numeric(), nullable=False), - sa.Column("recurring_month", sa.Integer(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint( - ["product_uuid"], - ["products.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - ) - op.create_table( - "users", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("customer_uuid", sa.UUID(), nullable=True), - sa.Column("name", sa.String(), nullable=False), - sa.Column("email", sa.String(), nullable=False), - sa.Column("role", sa.Enum("ADMIN", "NORMAL", name="roleenum"), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint( - ["customer_uuid"], - ["customers.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - sa.UniqueConstraint("email"), - ) - op.create_table( - "vouchers", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("code", sa.String(), nullable=False), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("product_plan_uuid", sa.UUID(), nullable=False), - sa.Column("discount_percentage", sa.Numeric(), nullable=True), - sa.Column("discount_fixed_amount", sa.Numeric(), nullable=True), - sa.Column("valid_from", sa.DateTime(), nullable=False), - sa.Column("valid_until", sa.DateTime(), nullable=False), - sa.Column("max_redemptions", sa.Integer(), nullable=False), - sa.Column("redeemed_count", sa.Integer(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint( - ["product_plan_uuid"], - ["product_plans.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - sa.UniqueConstraint("code"), - ) - op.create_table( - "customer_products", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("customer_uuid", sa.UUID(), nullable=False), - sa.Column("product_uuid", sa.UUID(), nullable=False), - sa.Column("product_plan_uuid", sa.UUID(), nullable=False), - sa.Column( - "status", - sa.Enum( - "TRIALING", - "ACTIVE", - "ACTIVATION", - "PAYMENT", - "SCHEDULED", - "CANCELED", - "EXPIRED", - "REFUNDED", - name="customerproductstatusenum", - ), - nullable=False, - ), - sa.Column("seats", sa.Integer(), nullable=True), - sa.Column("start_date", sa.DateTime(), nullable=False), - sa.Column("end_date", sa.DateTime(), nullable=False), - sa.Column("next_payment_date", sa.DateTime(), nullable=True), - sa.Column("voucher_uuid", sa.UUID(), nullable=True), - sa.Column("cancellation_date", sa.DateTime(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint( - ["customer_uuid"], - ["customers.uuid"], - ), - sa.ForeignKeyConstraint( - ["product_plan_uuid"], - ["product_plans.uuid"], - ), - sa.ForeignKeyConstraint( - ["product_uuid"], - ["products.uuid"], - ), - sa.ForeignKeyConstraint( - ["voucher_uuid"], - ["vouchers.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - ) - op.create_table( - "customer_product_contracts", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("product_customer_uuid", sa.UUID(), nullable=False), - sa.Column("contract_uuid", sa.UUID(), nullable=False), - sa.Column("accepted_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["contract_uuid"], - ["contracts.uuid"], - ), - sa.ForeignKeyConstraint( - ["product_customer_uuid"], - ["customer_products.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - ) - op.create_table( - "invoices", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("customer_uuid", sa.UUID(), nullable=False), - sa.Column("customer_product_uuid", sa.UUID(), nullable=False), - sa.Column("title", sa.String(), nullable=True), - sa.Column( - "status", - sa.Enum("PENDING", "PAID", "OVERDUE", name="invoicestatusenum"), - nullable=False, - ), - sa.Column("date", sa.DateTime(), nullable=False), - sa.Column("total_amount", sa.Numeric(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint( - ["customer_product_uuid"], - ["customer_products.uuid"], - ), - sa.ForeignKeyConstraint( - ["customer_uuid"], - ["customers.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("invoices") - op.drop_table("customer_product_contracts") - op.drop_table("customer_products") - op.drop_table("vouchers") - op.drop_table("users") - op.drop_table("product_plans") - op.drop_table("product_contracts") - op.drop_table("messages") - op.drop_table("products") - op.drop_table("customers") - op.drop_table("contracts") - # ### end Alembic commands ### diff --git a/app/migrations/versions/b2e7a1c3d4f5_message_customer_product_links.py b/app/migrations/versions/b2e7a1c3d4f5_message_customer_product_links.py deleted file mode 100644 index d7030ea..0000000 --- a/app/migrations/versions/b2e7a1c3d4f5_message_customer_product_links.py +++ /dev/null @@ -1,40 +0,0 @@ -"""message customer product booking references - -Revision ID: b2e7a1c3d4f5 -Revises: 0c5d68f9e4b1 -Create Date: 2026-04-07 14:00:00.000000 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -revision: str = "b2e7a1c3d4f5" -down_revision: Union[str, None] = "0c5d68f9e4b1" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "message_customer_products", - sa.Column("message_uuid", sa.UUID(), nullable=False), - sa.Column("customer_product_uuid", sa.UUID(), nullable=False), - sa.ForeignKeyConstraint( - ["customer_product_uuid"], - ["customer_products.uuid"], - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["message_uuid"], - ["messages.uuid"], - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("message_uuid", "customer_product_uuid"), - ) - - -def downgrade() -> None: - op.drop_table("message_customer_products") diff --git a/app/migrations/versions/c3f8b2a1e6d7_app_variables_table.py b/app/migrations/versions/c3f8b2a1e6d7_app_variables_table.py deleted file mode 100644 index 485f11e..0000000 --- a/app/migrations/versions/c3f8b2a1e6d7_app_variables_table.py +++ /dev/null @@ -1,31 +0,0 @@ -"""app_variables table - -Revision ID: c3f8b2a1e6d7 -Revises: b2e7a1c3d4f5 -Create Date: 2026-04-14 12:00:00.000000 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -revision: str = "c3f8b2a1e6d7" -down_revision: Union[str, None] = "b2e7a1c3d4f5" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "app_variables", - sa.Column("key", sa.String(length=128), nullable=False), - sa.Column("value", sa.Text(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint("key"), - ) - - -def downgrade() -> None: - op.drop_table("app_variables") diff --git a/app/migrations/versions/cffecd06b2ab_initial_schema.py b/app/migrations/versions/cffecd06b2ab_initial_schema.py new file mode 100644 index 0000000..b757cf4 --- /dev/null +++ b/app/migrations/versions/cffecd06b2ab_initial_schema.py @@ -0,0 +1,231 @@ +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'cffecd06b2ab' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('app_variables', + sa.Column('key', sa.String(length=128), nullable=False), + sa.Column('value', sa.Text(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('key') + ) + op.create_table('contracts', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('key', sa.Enum('AGB_APP_ZUM_DOC_PATIENT', 'AGB_MEDIQUU_CONNECT', 'AGB_APP_ZUM_DOC', 'AGB_MEDIQUU_NETZMANAGER', 'AGB_MEDIQUU_CHAT', 'PRIVACY_CONCEPT', 'PRIVACY_CONCEPT_TASKS', 'AVV', 'AVV_TASKS', 'NDA', 'SUB', 'TOM', name='contractkeyenum'), nullable=False), + sa.Column('version', sa.String(), nullable=False), + sa.Column('url', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_table('customer_addresses', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('address', sa.String(), nullable=False), + sa.Column('house_number', sa.String(), nullable=False), + sa.Column('care_of', sa.String(), nullable=True), + sa.Column('postal_code', sa.String(), nullable=False), + sa.Column('city', sa.String(), nullable=False), + sa.Column('country', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_table('products', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('slug', sa.String(), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('image_url', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_index(op.f('ix_products_slug'), 'products', ['slug'], unique=True) + op.create_table('customers', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('website_url', sa.String(), nullable=True), + sa.Column('phone_number', sa.String(), nullable=True), + sa.Column('registered_address_id', sa.UUID(), nullable=False), + sa.Column('invoice_address_id', sa.UUID(), nullable=True), + sa.Column('parent_customer_uuid', sa.UUID(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['invoice_address_id'], ['customer_addresses.uuid'], ), + sa.ForeignKeyConstraint(['parent_customer_uuid'], ['customers.uuid'], ), + sa.ForeignKeyConstraint(['registered_address_id'], ['customer_addresses.uuid'], ), + sa.PrimaryKeyConstraint('uuid'), + sa.UniqueConstraint('email') + ) + op.create_index(op.f('ix_customers_parent_customer_uuid'), 'customers', ['parent_customer_uuid'], unique=False) + op.create_table('product_contracts', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('product_uuid', sa.UUID(), nullable=False), + sa.Column('contract_uuid', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['contract_uuid'], ['contracts.uuid'], ), + sa.ForeignKeyConstraint(['product_uuid'], ['products.uuid'], ), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_table('product_plans', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('product_uuid', sa.UUID(), nullable=False), + sa.Column('type', sa.Enum('ONCE', 'RECURRING', 'LIFETIME', 'TRIAL', name='plantypeenum'), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('cost_euro', sa.Numeric(), nullable=False), + sa.Column('recurring_month', sa.Integer(), nullable=True), + sa.Column('notice_period_months', sa.Integer(), nullable=False), + sa.Column('payment_period_months', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['product_uuid'], ['products.uuid'], ), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_table('messages', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('customer_uuid', sa.UUID(), nullable=False), + sa.Column('subject', sa.String(), nullable=False), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('status', sa.Enum('UNREAD', 'READ', 'ARCHIVED', name='messagestatusenum'), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['customer_uuid'], ['customers.uuid'], ), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_table('users', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('customer_uuid', sa.UUID(), nullable=True), + sa.Column('name', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('role', sa.Enum('ADMIN', 'NORMAL', name='roleenum'), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['customer_uuid'], ['customers.uuid'], ), + sa.PrimaryKeyConstraint('uuid'), + sa.UniqueConstraint('email') + ) + op.create_table('vouchers', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('code', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('product_plan_uuid', sa.UUID(), nullable=False), + sa.Column('discount_percentage', sa.Numeric(), nullable=True), + sa.Column('discount_fixed_amount', sa.Numeric(), nullable=True), + sa.Column('valid_from', sa.DateTime(), nullable=False), + sa.Column('valid_until', sa.DateTime(), nullable=False), + sa.Column('max_redemptions', sa.Integer(), nullable=False), + sa.Column('redeemed_count', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['product_plan_uuid'], ['product_plans.uuid'], ), + sa.PrimaryKeyConstraint('uuid'), + sa.UniqueConstraint('code') + ) + op.create_table('customer_products', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('customer_uuid', sa.UUID(), nullable=False), + sa.Column('product_uuid', sa.UUID(), nullable=False), + sa.Column('product_plan_uuid', sa.UUID(), nullable=False), + sa.Column('status', sa.Enum('active', 'trial', 'canceled', 'deactive', name='booking_status_enum'), nullable=False), + sa.Column('service_status', sa.Enum('provisioning', 'degraded', 'online', 'deprovisioning', name='service_status_enum'), nullable=True), + sa.Column('seats', sa.Integer(), nullable=True), + sa.Column('start_date', sa.DateTime(), nullable=False), + sa.Column('end_date', sa.DateTime(), nullable=True), + sa.Column('next_payment_date', sa.DateTime(), nullable=True), + sa.Column('voucher_uuid', sa.UUID(), nullable=True), + sa.Column('cancellation_date', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['customer_uuid'], ['customers.uuid'], ), + sa.ForeignKeyConstraint(['product_plan_uuid'], ['product_plans.uuid'], ), + sa.ForeignKeyConstraint(['product_uuid'], ['products.uuid'], ), + sa.ForeignKeyConstraint(['voucher_uuid'], ['vouchers.uuid'], ), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_table('organization_invitations', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('customer_uuid', sa.UUID(), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('status', sa.Enum('pending', 'accepted', 'revoked', name='invitationstatusenum'), nullable=False), + sa.Column('invited_by_user_uuid', sa.UUID(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['customer_uuid'], ['customers.uuid'], ), + sa.ForeignKeyConstraint(['invited_by_user_uuid'], ['users.uuid'], ), + sa.PrimaryKeyConstraint('uuid'), + sa.UniqueConstraint('token_hash') + ) + op.create_index('ix_organization_invitations_email_status', 'organization_invitations', ['email', 'status'], unique=False) + op.create_table('user_customers', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('user_uuid', sa.UUID(), nullable=False), + sa.Column('customer_uuid', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['customer_uuid'], ['customers.uuid'], ), + sa.ForeignKeyConstraint(['user_uuid'], ['users.uuid'], ), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_index('ix_user_customers_user_customer', 'user_customers', ['user_uuid', 'customer_uuid'], unique=True) + op.create_table('customer_product_contracts', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('product_customer_uuid', sa.UUID(), nullable=False), + sa.Column('contract_uuid', sa.UUID(), nullable=False), + sa.Column('accepted_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['contract_uuid'], ['contracts.uuid'], ), + sa.ForeignKeyConstraint(['product_customer_uuid'], ['customer_products.uuid'], ), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_table('invoices', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('customer_uuid', sa.UUID(), nullable=False), + sa.Column('customer_product_uuid', sa.UUID(), nullable=False), + sa.Column('title', sa.String(), nullable=True), + sa.Column('status', sa.Enum('PENDING', 'PAID', 'OVERDUE', name='invoicestatusenum'), nullable=False), + sa.Column('date', sa.DateTime(), nullable=False), + sa.Column('total_amount', sa.Numeric(), nullable=False), + sa.Column('sequence_number', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['customer_product_uuid'], ['customer_products.uuid'], ), + sa.ForeignKeyConstraint(['customer_uuid'], ['customers.uuid'], ), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_table('message_customer_products', + sa.Column('message_uuid', sa.UUID(), nullable=False), + sa.Column('customer_product_uuid', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['customer_product_uuid'], ['customer_products.uuid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['message_uuid'], ['messages.uuid'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('message_uuid', 'customer_product_uuid') + ) + + +def downgrade() -> None: + op.drop_table('message_customer_products') + op.drop_table('invoices') + op.drop_table('customer_product_contracts') + op.drop_index('ix_user_customers_user_customer', table_name='user_customers') + op.drop_table('user_customers') + op.drop_index('ix_organization_invitations_email_status', table_name='organization_invitations') + op.drop_table('organization_invitations') + op.drop_table('customer_products') + op.drop_table('vouchers') + op.drop_table('users') + op.drop_table('messages') + op.drop_table('product_plans') + op.drop_table('product_contracts') + op.drop_index(op.f('ix_customers_parent_customer_uuid'), table_name='customers') + op.drop_table('customers') + op.drop_index(op.f('ix_products_slug'), table_name='products') + op.drop_table('products') + op.drop_table('customer_addresses') + op.drop_table('contracts') + op.drop_table('app_variables') diff --git a/app/migrations/versions/d4e8f9a0b1c2_organization_invitations.py b/app/migrations/versions/d4e8f9a0b1c2_organization_invitations.py deleted file mode 100644 index 51c8718..0000000 --- a/app/migrations/versions/d4e8f9a0b1c2_organization_invitations.py +++ /dev/null @@ -1,60 +0,0 @@ -"""organization_invitations table - -Revision ID: d4e8f9a0b1c2 -Revises: c3f8b2a1e6d7 -Create Date: 2026-04-14 14:00:00.000000 -""" - -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - - -revision: str = "d4e8f9a0b1c2" -down_revision: Union[str, None] = "c3f8b2a1e6d7" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "organization_invitations", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("customer_uuid", sa.UUID(), nullable=False), - sa.Column("email", sa.String(length=255), nullable=False), - sa.Column("token_hash", sa.String(length=64), nullable=False), - sa.Column("expires_at", sa.DateTime(), nullable=False), - sa.Column( - "status", - sa.Enum("pending", "accepted", "revoked", name="invitationstatusenum"), - nullable=False, - ), - sa.Column("invited_by_user_uuid", sa.UUID(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["customer_uuid"], - ["customers.uuid"], - ), - sa.ForeignKeyConstraint( - ["invited_by_user_uuid"], - ["users.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - sa.UniqueConstraint("token_hash"), - ) - op.create_index( - "ix_organization_invitations_email_status", - "organization_invitations", - ["email", "status"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index( - "ix_organization_invitations_email_status", - table_name="organization_invitations", - ) - op.drop_table("organization_invitations") - op.execute("DROP TYPE IF EXISTS invitationstatusenum") diff --git a/app/models/customer.py b/app/models/customer.py index 2620a2c..1d51622 100644 --- a/app/models/customer.py +++ b/app/models/customer.py @@ -1,14 +1,24 @@ +from __future__ import annotations + import re from datetime import datetime -from typing import Annotated +from typing import Annotated, Any from uuid import UUID as UUID4 from uuid import uuid4 -from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator -from sqlalchemy import Column, DateTime, String +from pydantic import ( + BaseModel, + ConfigDict, + EmailStr, + Field, + field_validator, + model_validator, +) +from sqlalchemy import Column, DateTime, ForeignKey, String from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship +from models.customer_address import CustomerAddress from models.customer_product import CustomerProduct from models.invoice import Invoice from models.message import Message @@ -19,8 +29,8 @@ POSTAL_CODE_PATTERN, WEB_URL_PATTERN, ) -from models.user_customer import UserCustomer from models.user import User +from models.user_customer import UserCustomer from utils.database.connection import Base MAX_CUSTOMER_NAME_LEN = 255 @@ -38,15 +48,49 @@ class Customer(Base): website_url = Column(String) phone_number = Column(String) - address = Column(String, nullable=False) - house_number = Column(String, nullable=False) - care_of = Column(String) - postal_code = Column(String, nullable=False) - city = Column(String, nullable=False) - country = Column(String, nullable=False) + + registered_address_id = Column( + UUID(as_uuid=True), + ForeignKey("customer_addresses.uuid"), + nullable=False, + ) + invoice_address_id = Column( + UUID(as_uuid=True), + ForeignKey("customer_addresses.uuid"), + nullable=True, + ) + parent_customer_uuid = Column( + UUID(as_uuid=True), + ForeignKey("customers.uuid"), + nullable=True, + index=True, + ) + created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + registered_address = relationship( + "CustomerAddress", + foreign_keys=[registered_address_id], + lazy="selectin", + ) + invoice_address = relationship( + "CustomerAddress", + foreign_keys=[invoice_address_id], + lazy="selectin", + ) + parent_customer = relationship( + "Customer", + remote_side=[uuid], + foreign_keys=[parent_customer_uuid], + back_populates="sub_organizations", + ) + sub_organizations = relationship( + "Customer", + back_populates="parent_customer", + foreign_keys=[parent_customer_uuid], + ) + users = relationship(User, back_populates="customer") user_customers = relationship(UserCustomer, back_populates="customer") products = relationship(CustomerProduct, back_populates="customer") @@ -57,29 +101,50 @@ class Customer(Base): ) -class CustomerBase(BaseModel): +class AddressRead(BaseModel): model_config = ConfigDict(from_attributes=True) uuid: UUID4 - name: str - phone_number: str | None - website_url: str | None address: str house_number: str care_of: str | None postal_code: str city: str country: str + + +class CustomerBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + + uuid: UUID4 + name: str + phone_number: str | None + website_url: str | None email: EmailStr + registered_address: AddressRead + invoice_address: AddressRead | None + parent_customer_uuid: UUID4 | None created_at: datetime updated_at: datetime -class CustomerUpdate(BaseModel): - name: Annotated[str, Field(min_length=1, max_length=MAX_CUSTOMER_NAME_LEN)] - email: EmailStr - phone_number: str | None = None - website_url: str | None = None +def serialize_customer(customer: Customer) -> CustomerBase: + inv = customer.invoice_address if customer.invoice_address_id else None + return CustomerBase( + uuid=customer.uuid, + name=customer.name, + phone_number=customer.phone_number, + website_url=customer.website_url, + email=customer.email, + registered_address=AddressRead.model_validate(customer.registered_address), + invoice_address=AddressRead.model_validate(inv) if inv else None, + parent_customer_uuid=customer.parent_customer_uuid, + created_at=customer.created_at, + updated_at=customer.updated_at, + ) + + +class AddressWrite(BaseModel): address: Annotated[str, Field(min_length=1, max_length=MAX_ADDRESS_LEN)] house_number: str care_of: str | None = None @@ -87,38 +152,6 @@ class CustomerUpdate(BaseModel): city: Annotated[str, Field(min_length=1, max_length=MAX_CITY_LEN)] country: str - @field_validator("phone_number", mode="before") - @classmethod - def normalize_empty_phone(cls, value: object) -> object: - if value == "" or value is None: - return None - return value - - @field_validator("phone_number") - @classmethod - def validate_phone(cls, value: str | None) -> str | None: - if value is None: - return None - if not re.fullmatch(PHONE_PATTERN, value): - raise ValueError("phone_number_invalid") - return value - - @field_validator("website_url", mode="before") - @classmethod - def normalize_empty_website(cls, value: object) -> object: - if value == "" or value is None: - return None - return value - - @field_validator("website_url") - @classmethod - def validate_website(cls, value: str | None) -> str | None: - if value is None: - return None - if not re.fullmatch(WEB_URL_PATTERN, value): - raise ValueError("website_url_invalid") - return value - @field_validator("care_of", mode="before") @classmethod def normalize_care_of(cls, value: object) -> object: @@ -155,3 +188,96 @@ def validate_country(cls, value: str) -> str: if value not in ORGANIZATION_COUNTRY_ENGLISH_NAMES: raise ValueError("country_not_allowed") return value + + +class CustomerUpdate(BaseModel): + name: Annotated[str, Field(min_length=1, max_length=MAX_CUSTOMER_NAME_LEN)] + email: EmailStr + phone_number: str | None = None + website_url: str | None = None + registered_address: AddressWrite + invoice_matches_registered: bool = True + invoice_address: AddressWrite | None = None + + @field_validator("phone_number", mode="before") + @classmethod + def normalize_empty_phone(cls, value: object) -> object: + if value == "" or value is None: + return None + return value + + @field_validator("phone_number") + @classmethod + def validate_phone(cls, value: str | None) -> str | None: + if value is None: + return None + if not re.fullmatch(PHONE_PATTERN, value): + raise ValueError("phone_number_invalid") + return value + + @field_validator("website_url", mode="before") + @classmethod + def normalize_empty_website(cls, value: object) -> object: + if value == "" or value is None: + return None + return value + + @field_validator("website_url") + @classmethod + def validate_website(cls, value: str | None) -> str | None: + if value is None: + return None + if not re.fullmatch(WEB_URL_PATTERN, value): + raise ValueError("website_url_invalid") + return value + + @model_validator(mode="after") + def require_invoice_when_split(self) -> CustomerUpdate: + if not self.invoice_matches_registered and self.invoice_address is None: + raise ValueError("invoice_address_required_when_split") + return self + + +def apply_address_write_to_row(addr: CustomerAddress, data: AddressWrite) -> None: + addr.address = data.address + addr.house_number = data.house_number + addr.care_of = data.care_of + addr.postal_code = data.postal_code + addr.city = data.city + addr.country = data.country + + +def create_address_row(session, data: AddressWrite) -> CustomerAddress: + row = CustomerAddress( + address=data.address, + house_number=data.house_number, + care_of=data.care_of, + postal_code=data.postal_code, + city=data.city, + country=data.country, + ) + session.add(row) + session.flush() + return row + + +def update_customer_addresses(session: Any, customer: Customer, data: CustomerUpdate) -> None: + apply_address_write_to_row(customer.registered_address, data.registered_address) + if data.invoice_matches_registered: + old_inv_id = customer.invoice_address_id + if old_inv_id and old_inv_id != customer.registered_address_id: + customer.invoice_address_id = None + session.flush() + orphan = session.get(CustomerAddress, old_inv_id) + if orphan: + session.delete(orphan) + else: + customer.invoice_address_id = None + return + assert data.invoice_address is not None + if customer.invoice_address_id and customer.invoice_address_id != customer.registered_address_id: + inv_row = customer.invoice_address + apply_address_write_to_row(inv_row, data.invoice_address) + return + inv_row = create_address_row(session, data.invoice_address) + customer.invoice_address_id = inv_row.uuid diff --git a/app/models/customer_address.py b/app/models/customer_address.py new file mode 100644 index 0000000..99b300d --- /dev/null +++ b/app/models/customer_address.py @@ -0,0 +1,21 @@ +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import Column, DateTime, String +from sqlalchemy.dialects.postgresql import UUID + +from utils.database.connection import Base + + +class CustomerAddress(Base): + __tablename__ = "customer_addresses" + + uuid = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) + address = Column(String, nullable=False) + house_number = Column(String, nullable=False) + care_of = Column(String) + postal_code = Column(String, nullable=False) + city = Column(String, nullable=False) + country = Column(String, nullable=False) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) diff --git a/app/models/customer_product.py b/app/models/customer_product.py index 87613c9..dcafd12 100644 --- a/app/models/customer_product.py +++ b/app/models/customer_product.py @@ -9,7 +9,7 @@ from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from utils.database.connection import Base -from models.static import CustomerProductStatusEnum +from models.static import CustomerProductStatusEnum, InvoiceStatusEnum, ServiceStatusEnum class CustomerProduct(Base): @@ -26,10 +26,22 @@ class CustomerProduct(Base): UUID(as_uuid=True), ForeignKey("product_plans.uuid"), nullable=False ) status = Column( - Enum(CustomerProductStatusEnum), + Enum( + CustomerProductStatusEnum, + name="booking_status_enum", + values_callable=lambda obj: [e.value for e in obj], + ), nullable=False, default=CustomerProductStatusEnum.ACTIVE, ) + service_status = Column( + Enum( + ServiceStatusEnum, + name="service_status_enum", + values_callable=lambda obj: [e.value for e in obj], + ), + nullable=True, + ) seats = Column(Integer, nullable=True) start_date = Column(DateTime, nullable=False) end_date = Column(DateTime, nullable=True) @@ -56,10 +68,15 @@ class CustomerProductBase(BaseModel): seats: int | None start_date: datetime next_payment_date: datetime | None + next_invoice_date: datetime | None = None + next_cancellation_deadline: datetime | None = None + primary_invoice_uuid: UUID4 | None = None voucher_uuid: UUID4 | None cancellation_date: datetime | None created_at: datetime updated_at: datetime + service_status: ServiceStatusEnum | None = None + invoice_status: InvoiceStatusEnum | None = None class ExtendedCustomerProductBase(BaseModel): @@ -68,13 +85,18 @@ class ExtendedCustomerProductBase(BaseModel): product: ProductBase product_plan: ProductPlanBase status: CustomerProductStatusEnum + invoice_status: InvoiceStatusEnum seats: int | None start_date: datetime next_payment_date: datetime | None + next_invoice_date: datetime | None = None + next_cancellation_deadline: datetime | None = None + primary_invoice_uuid: UUID4 | None = None voucher_uuid: UUID4 | None cancellation_date: datetime | None created_at: datetime updated_at: datetime + service_status: ServiceStatusEnum | None = None class CustomerProductCreate(BaseModel): diff --git a/app/models/invoice.py b/app/models/invoice.py index 7189f5c..2fda863 100644 --- a/app/models/invoice.py +++ b/app/models/invoice.py @@ -3,8 +3,8 @@ from uuid import UUID as UUID4 from uuid import uuid4 -from pydantic import BaseModel -from sqlalchemy import Column, DateTime, Enum, ForeignKey, Numeric, String +from pydantic import BaseModel, ConfigDict +from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, Numeric, String from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship @@ -32,6 +32,7 @@ class Invoice(Base): ) date = Column(DateTime, nullable=False) total_amount = Column(Numeric, nullable=False) + sequence_number = Column(Integer, nullable=False) created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) @@ -40,11 +41,15 @@ class Invoice(Base): class InvoiceBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + uuid: UUID4 + customer_product_uuid: UUID4 title: str | None status: InvoiceStatusEnum date: datetime total_amount: float + sequence_number: int created_at: datetime updated_at: datetime diff --git a/app/models/product_plan.py b/app/models/product_plan.py index b3bb072..4854af3 100644 --- a/app/models/product_plan.py +++ b/app/models/product_plan.py @@ -22,6 +22,8 @@ class ProductPlan(Base): name = Column(String, nullable=False) cost_euro = Column(Numeric, nullable=False) recurring_month = Column(Integer, nullable=True) + notice_period_months = Column(Integer, nullable=False, default=1) + payment_period_months = Column(Integer, nullable=False, default=1) created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) @@ -37,5 +39,7 @@ class ProductPlanBase(BaseModel): name: str cost_euro: float recurring_month: int + notice_period_months: int + payment_period_months: int created_at: datetime updated_at: datetime diff --git a/app/models/static.py b/app/models/static.py index d1acf3a..cde1934 100644 --- a/app/models/static.py +++ b/app/models/static.py @@ -90,14 +90,17 @@ class CustomerProductStatusEnum(PyEnum): - TRIALING = "trialing" ACTIVE = "active" - ACTIVATION = "activation" - PAYMENT = "payment" - SCHEDULED = "scheduled" + TRIAL = "trial" CANCELED = "canceled" - EXPIRED = "expired" - REFUNDED = "refunded" + DEACTIVE = "deactive" + + +class ServiceStatusEnum(PyEnum): + PROVISIONING = "provisioning" + DEGRADED = "degraded" + ONLINE = "online" + DEPROVISIONING = "deprovisioning" class RoleEnum(PyEnum): diff --git a/app/namespaces/customer/customer.py b/app/namespaces/customer/customer.py index a75e23b..61932bf 100644 --- a/app/namespaces/customer/customer.py +++ b/app/namespaces/customer/customer.py @@ -5,7 +5,14 @@ from keycloak.exceptions import KeycloakError from pydantic import BaseModel -from models.customer import Customer, CustomerBase, CustomerUpdate +from models.customer import ( + Customer, + CustomerBase, + CustomerUpdate, + create_address_row, + serialize_customer, + update_customer_addresses, +) from models.static import ORGANIZATION_COUNTRY_ENGLISH_NAMES, RoleEnum from models.user import User from models.user_customer import UserCustomer @@ -81,18 +88,20 @@ async def create( except (TypeError, ValueError): requested_uuid = None + registered = create_address_row(session, data.registered_address) + invoice_id = None + if not data.invoice_matches_registered: + inv = create_address_row(session, data.invoice_address) + invoice_id = inv.uuid + customer = Customer( uuid=requested_uuid, name=data.name, email=data.email, website_url=data.website_url, phone_number=data.phone_number, - address=data.address, - house_number=data.house_number, - care_of=data.care_of, - postal_code=data.postal_code, - city=data.city, - country=data.country, + registered_address_id=registered.uuid, + invoice_address_id=invoice_id, ) session.add(customer) @@ -121,7 +130,7 @@ async def create( session.commit() session.refresh(customer) - return customer + return serialize_customer(customer) @protected_router.get("/me", response_model=MeResponse) @@ -160,7 +169,7 @@ async def read_me( email=oidc_user.email, email_verified=oidc_user.email_verified, name=user.name, - customer=CustomerBase.model_validate(user.customer) if user.customer else None, + customer=serialize_customer(user.customer) if user.customer else None, organizations=organizations, ) @@ -266,7 +275,7 @@ async def read(user: User = Depends(get_user)): if not customer: raise HTTPException(status_code=404, detail="Customer not found.") - return customer + return serialize_customer(customer) @protected_router.put("/", response_model=CustomerBase) @@ -284,14 +293,9 @@ async def update( customer.email = data.email customer.website_url = data.website_url customer.phone_number = data.phone_number - customer.address = data.address - customer.house_number = data.house_number - customer.care_of = data.care_of - customer.postal_code = data.postal_code - customer.city = data.city - customer.country = data.country + update_customer_addresses(session, customer, data) session.commit() session.refresh(customer) - return customer + return serialize_customer(customer) diff --git a/app/namespaces/customer/customer_product.py b/app/namespaces/customer/customer_product.py index fa71adb..99fc0af 100644 --- a/app/namespaces/customer/customer_product.py +++ b/app/namespaces/customer/customer_product.py @@ -1,3 +1,4 @@ +import logging from datetime import datetime from uuid import UUID @@ -16,18 +17,64 @@ ) from models.customer_product_contract import CustomerProductContract from models.invoice import Invoice -from models.product import Product +from models.product import Product, ProductBase from models.product_contract import ProductContract -from models.product_plan import ProductPlan -from models.static import CustomerProductStatusEnum, PlanTypeEnum +from models.product_plan import ProductPlan, ProductPlanBase +from models.static import ( + CustomerProductStatusEnum, + PlanTypeEnum, + ServiceStatusEnum, +) from models.voucher import Voucher from utils.calculation.pricing import ( calculate_full_pricing_in_euro, calculate_pricing_in_euro, ) +from sqlalchemy import func +from sqlalchemy.orm import joinedload, selectinload +from utils.app_variables import AppVariables +from utils.config import settings from utils.database.session import get_database +from utils.mailing.booking import ( + build_product_booked_mail_data, + send_product_booked_mail, +) +from utils.mailing.cancellation import ( + build_product_cancellation_mail_data, + send_product_cancellation_mail, +) +from utils.mailing.mailer import mailer_from_settings +from utils.helpers.customer_product_status import ( + apply_canceled_to_deactive, + derive_invoice_status, + has_blocking_customer_product_booking, + primary_invoice_uuid_for_link, +) +from utils.scheduling.subscription_dates import ( + compute_next_cancellation_deadline, + compute_next_invoice_datetime, +) from utils.security.token import get_user +logger = logging.getLogger(__name__) + + +def _schedule_fields_for_customer_product( + cp: CustomerProduct, + invoices: list[Invoice], + now: datetime, +) -> tuple[datetime | None, datetime | None, UUID | None]: + pid = primary_invoice_uuid_for_link(invoices) + plan = cp.product_plan + if plan is None: + return None, None, pid + ni = compute_next_invoice_datetime( + cp.start_date, plan.payment_period_months, now + ) + nc = compute_next_cancellation_deadline(ni, plan.notice_period_months) + return ni, nc, pid + + public_router = APIRouter(prefix="/product", tags=["CustomerProduct"]) protected_router = APIRouter(prefix="/product", tags=["CustomerProduct"]) @@ -48,10 +95,10 @@ async def create( if not product: raise HTTPException(404, detail="Product not found.") - if ( - session.query(CustomerProduct.uuid) - .filter_by(product_uuid=product.uuid, customer_uuid=user.customer_uuid) - .first() + if has_blocking_customer_product_booking( + session, + customer_uuid=user.customer_uuid, + product_uuid=product.uuid, ): raise HTTPException(400, detail="Product already booked.") @@ -101,7 +148,8 @@ async def create( product_uuid=data.product_uuid, product_plan_uuid=data.product_plan_uuid, voucher_uuid=data.voucher_uuid, - status=CustomerProductStatusEnum.PAYMENT, + status=CustomerProductStatusEnum.ACTIVE, + service_status=ServiceStatusEnum.PROVISIONING, start_date=datetime.now(), end_date=( datetime.now() + relativedelta(months=+product_plan.recurring_month) @@ -121,38 +169,182 @@ async def create( ) session.add(customer_product_contract) + max_seq = ( + session.query(func.max(Invoice.sequence_number)) + .filter(Invoice.customer_product_uuid == customer_product.uuid) + .scalar() + ) + next_seq = (max_seq or 0) + 1 invoice = Invoice( customer_uuid=user.customer.uuid, customer_product_uuid=customer_product.uuid, title=f"{product.name} ({product_plan.name})", date=datetime.now(), total_amount=calculate_pricing_in_euro(product_plan, voucher), + sequence_number=next_seq, ) session.add(invoice) session.commit() - return customer_product + session.refresh(customer_product) + mail_data_booking = build_product_booked_mail_data( + settings=settings, + recipient_name=user.name, + organization_name=user.customer.name, + product_name=product.name, + plan_name=product_plan.name, + start_date=customer_product.start_date, + seats=customer_product.seats, + ) + mailer_booking = mailer_from_settings(settings) + template_id_booking = AppVariables(session).product_booked_mail_template_id() + if mailer_booking and template_id_booking is not None: + try: + send_product_booked_mail( + session, + settings, + to_email=user.customer.email, + data=mail_data_booking, + ) + except Exception: + logger.exception( + "Product booked confirmation mail failed customer_product_uuid=%s", + customer_product.uuid, + ) + else: + logger.info( + "Product booked confirmation mail skipped customer_product_uuid=%s", + customer_product.uuid, + ) + invoices = ( + session.query(Invoice) + .filter_by(customer_product_uuid=customer_product.uuid) + .all() + ) + now = datetime.now() + ni = compute_next_invoice_datetime( + customer_product.start_date, product_plan.payment_period_months, now + ) + nc = compute_next_cancellation_deadline(ni, product_plan.notice_period_months) + pid = primary_invoice_uuid_for_link(invoices) + return CustomerProductBase( + uuid=customer_product.uuid, + customer_uuid=customer_product.customer_uuid, + product_uuid=customer_product.product_uuid, + product_plan_uuid=customer_product.product_plan_uuid, + status=customer_product.status, + service_status=customer_product.service_status, + invoice_status=derive_invoice_status(invoices), + seats=customer_product.seats, + start_date=customer_product.start_date, + next_payment_date=customer_product.next_payment_date, + next_invoice_date=ni, + next_cancellation_deadline=nc, + primary_invoice_uuid=pid, + voucher_uuid=customer_product.voucher_uuid, + cancellation_date=customer_product.cancellation_date, + created_at=customer_product.created_at, + updated_at=customer_product.updated_at, + ) @protected_router.get("/{uuid}", response_model=CustomerProductBase) async def read( uuid: UUID, user: User = Depends(get_user), session=Depends(get_database) ): - customer_product = session.query(CustomerProduct).filter_by(uuid=uuid).first() + customer_product = ( + session.query(CustomerProduct) + .options( + selectinload(CustomerProduct.invoices), + selectinload(CustomerProduct.product_plan), + ) + .filter_by(uuid=uuid) + .first() + ) if not customer_product or customer_product.customer_uuid != user.customer_uuid: raise HTTPException(status_code=404, detail="Product not found.") - return customer_product + products = [customer_product] + apply_canceled_to_deactive(session, products) + session.refresh(customer_product) + + now = datetime.now() + ni, nc, pid = _schedule_fields_for_customer_product( + customer_product, customer_product.invoices, now + ) + return CustomerProductBase( + uuid=customer_product.uuid, + customer_uuid=customer_product.customer_uuid, + product_uuid=customer_product.product_uuid, + product_plan_uuid=customer_product.product_plan_uuid, + status=customer_product.status, + service_status=customer_product.service_status, + invoice_status=derive_invoice_status(customer_product.invoices), + seats=customer_product.seats, + start_date=customer_product.start_date, + next_payment_date=customer_product.next_payment_date, + next_invoice_date=ni, + next_cancellation_deadline=nc, + primary_invoice_uuid=pid, + voucher_uuid=customer_product.voucher_uuid, + cancellation_date=customer_product.cancellation_date, + created_at=customer_product.created_at, + updated_at=customer_product.updated_at, + ) @protected_router.get("/self/", response_model=list[ExtendedCustomerProductBase]) -async def read_all_by_customer(user: User = Depends(get_user)): +async def read_all_by_customer( + user: User = Depends(get_user), session=Depends(get_database) +): if not user.customer: return [] - return user.customer.products + products = ( + session.query(CustomerProduct) + .options( + selectinload(CustomerProduct.product).selectinload(Product.plans), + selectinload(CustomerProduct.product_plan), + selectinload(CustomerProduct.invoices), + ) + .filter(CustomerProduct.customer_uuid == user.customer.uuid) + .all() + ) + + apply_canceled_to_deactive(session, products) + for cp in products: + session.refresh(cp) + + now = datetime.now() + result: list[ExtendedCustomerProductBase] = [] + for cp in products: + ni, nc, pid = _schedule_fields_for_customer_product(cp, cp.invoices, now) + result.append( + ExtendedCustomerProductBase( + uuid=cp.uuid, + customer_uuid=cp.customer_uuid, + product=ProductBase.model_validate(cp.product, from_attributes=True), + product_plan=ProductPlanBase.model_validate( + cp.product_plan, from_attributes=True + ), + status=cp.status, + invoice_status=derive_invoice_status(cp.invoices), + seats=cp.seats, + start_date=cp.start_date, + next_payment_date=cp.next_payment_date, + next_invoice_date=ni, + next_cancellation_deadline=nc, + primary_invoice_uuid=pid, + voucher_uuid=cp.voucher_uuid, + cancellation_date=cp.cancellation_date, + created_at=cp.created_at, + updated_at=cp.updated_at, + service_status=cp.service_status, + ) + ) + return result @public_router.post("/calculate/", response_model=CustomerProductsCalculation) @@ -237,12 +429,62 @@ async def calculate( async def delete( uuid: UUID, user: User = Depends(get_user), session=Depends(get_database) ): - customer_product = session.query(CustomerProduct).filter_by(uuid=uuid).first() + customer_product = ( + session.query(CustomerProduct) + .options( + joinedload(CustomerProduct.product), + joinedload(CustomerProduct.product_plan), + selectinload(CustomerProduct.invoices), + ) + .filter_by(uuid=uuid) + .first() + ) if not customer_product or customer_product.customer_uuid != user.customer_uuid: raise HTTPException(status_code=404, detail="Product not found.") - customer_product.status = CustomerProductStatusEnum.SCHEDULED - customer_product.cancellation_date = datetime.now() + if not user.customer: + raise HTTPException(status_code=404, detail="Customer not found.") + + now = datetime.now() + invoices = list(customer_product.invoices) + plan = customer_product.product_plan + product = customer_product.product + + mail_data = build_product_cancellation_mail_data( + settings=settings, + organization_name=user.customer.name, + product_name=product.name, + plan_name=plan.name, + plan=plan, + product_start_date=customer_product.start_date, + cancellation_requested_at=now, + subscription_end_date=customer_product.end_date, + invoices=invoices, + ) + + customer_product.status = CustomerProductStatusEnum.CANCELED + customer_product.cancellation_date = now session.commit() + + mailer = mailer_from_settings(settings) + template_id = AppVariables(session).cancellation_mail_template_id() + if mailer and template_id is not None: + try: + send_product_cancellation_mail( + session, + settings, + to_email=user.customer.email, + data=mail_data, + ) + except Exception: + logger.exception( + "Product cancellation mail failed customer_product_uuid=%s", + uuid, + ) + else: + logger.info( + "Product cancellation mail skipped customer_product_uuid=%s", + uuid, + ) diff --git a/app/namespaces/customer/invoice.py b/app/namespaces/customer/invoice.py index a1b044e..a623058 100644 --- a/app/namespaces/customer/invoice.py +++ b/app/namespaces/customer/invoice.py @@ -5,7 +5,7 @@ from models.customer_product import CustomerProduct from models.invoice import Invoice, InvoiceBase, InvoicePayRequest, InvoiceStatusEnum -from models.static import CustomerProductStatusEnum +from models.static import ServiceStatusEnum from models.user import User from utils.config import settings, stripe_return_url from utils.database.session import get_database @@ -81,10 +81,12 @@ async def read_one( raise HTTPException(404, detail="Invoice not found.") return InvoiceBase( uuid=invoice.uuid, + customer_product_uuid=invoice.customer_product_uuid, title=invoice.title, status=invoice.status, date=invoice.date, total_amount=float(invoice.total_amount), + sequence_number=invoice.sequence_number, created_at=invoice.created_at, updated_at=invoice.updated_at, ) @@ -105,7 +107,7 @@ async def pay( if invoice.status == InvoiceStatusEnum.PAID: raise HTTPException(400, detail="Invoice already paid.") - invoice.customer_product.status = CustomerProductStatusEnum.PAYMENT + invoice.customer_product.service_status = ServiceStatusEnum.PROVISIONING session.commit() session = stripe.checkout.Session.create( @@ -160,7 +162,7 @@ async def subscribe( if invoice.status == InvoiceStatusEnum.PAID: raise HTTPException(400, detail="Invoice already paid.") - invoice.customer_product.status = CustomerProductStatusEnum.PAYMENT + invoice.customer_product.service_status = ServiceStatusEnum.PROVISIONING session.commit() session = stripe.checkout.Session.create( @@ -265,9 +267,7 @@ async def stripe_webhook( .first() ) if customer_product: - customer_product.status = CustomerProductStatusEnum.ACTIVATION - # TODO: trigger outbound email once provisioning starts - # TODO: trigger Airflow DAG for product provisioning workflow + customer_product.service_status = ServiceStatusEnum.ONLINE session.commit() return {"status": "success"} diff --git a/app/namespaces/customer/product.py b/app/namespaces/customer/product.py index 6b6d21f..fe34850 100644 --- a/app/namespaces/customer/product.py +++ b/app/namespaces/customer/product.py @@ -3,8 +3,10 @@ from fastapi import APIRouter, Depends, HTTPException from models.customer_product import CustomerProduct from models.product import Product, ProductBase +from models.static import CustomerProductStatusEnum from models.user import User from sqlalchemy import select +from sqlalchemy.orm import selectinload from utils.database.session import get_database from utils.security.token import get_user @@ -33,15 +35,20 @@ async def available(user: User = Depends(get_user), session=Depends(get_database if not user.customer: raise HTTPException(status_code=404, detail="Customer not created yet.") - booked_product_uuids = ( + blocking_product_uuids = ( session.query(CustomerProduct.product_uuid) - .filter(CustomerProduct.customer_uuid == user.customer_uuid) + .filter( + CustomerProduct.customer_uuid == user.customer_uuid, + CustomerProduct.status != CustomerProductStatusEnum.DEACTIVE, + ) + .distinct() .subquery() ) - available_products = ( + rows = ( session.query(Product) - .filter(~Product.uuid.in_(select(booked_product_uuids.c.product_uuid))) + .options(selectinload(Product.plans)) + .filter(~Product.uuid.in_(select(blocking_product_uuids.c.product_uuid))) .all() ) - return available_products + return [product for product in rows if product.plans] diff --git a/app/namespaces/team/admin.py b/app/namespaces/team/admin.py index b0345e3..d41795f 100644 --- a/app/namespaces/team/admin.py +++ b/app/namespaces/team/admin.py @@ -7,7 +7,7 @@ from pydantic import BaseModel from models.contract import Contract -from models.customer import Customer +from models.customer import AddressRead, Customer, serialize_customer from models.product import Product from models.product_contract import ProductContract from models.product_plan import ProductPlan @@ -15,6 +15,10 @@ from models.voucher import Voucher from namespaces.team.app_variables import router as app_variables_router from utils.database.session import get_database +from utils.keycloak_impersonation import ( + fetch_keycloak_impersonation_redirect_url, + pick_organization_member_for_impersonation, +) from utils.security.token import UserInfo, get_team_user router = APIRouter(prefix="/admin", tags=["Admin"]) @@ -58,6 +62,8 @@ class ProductPlanPayload(BaseModel): name: str cost_euro: float recurring_month: int | None = None + notice_period_months: int = 1 + payment_period_months: int = 1 class ProductPayload(BaseModel): @@ -94,6 +100,8 @@ class ProductPlanRead(BaseModel): name: str cost_euro: float recurring_month: int | None + notice_period_months: int + payment_period_months: int created_at: datetime updated_at: datetime @@ -141,8 +149,9 @@ class CustomerDetail(BaseModel): email: str website_url: str | None phone_number: str | None - city: str - country: str + registered_address: AddressRead + invoice_address: AddressRead | None + parent_customer_uuid: UUID | None users: list[dict] customer_products: list[dict] invoices: list[dict] @@ -150,6 +159,11 @@ class CustomerDetail(BaseModel): updated_at: datetime +class CustomerImpersonationResponse(BaseModel): + redirect_url: str + target_user_email: str + + def _as_contract_read(contract: Contract) -> ContractRead: return ContractRead( uuid=contract.uuid, @@ -169,6 +183,8 @@ def _as_product_read(product: Product) -> ProductRead: name=plan.name, cost_euro=float(plan.cost_euro), recurring_month=plan.recurring_month, + notice_period_months=plan.notice_period_months, + payment_period_months=plan.payment_period_months, created_at=plan.created_at, updated_at=plan.updated_at, ) @@ -286,6 +302,8 @@ async def create_product( name=plan_data.name, cost_euro=Decimal(str(plan_data.cost_euro)), recurring_month=plan_data.recurring_month, + notice_period_months=max(1, plan_data.notice_period_months), + payment_period_months=max(1, plan_data.payment_period_months), ) ) @@ -335,6 +353,8 @@ async def update_product( plan.name = incoming_plan.name plan.cost_euro = Decimal(str(incoming_plan.cost_euro)) plan.recurring_month = incoming_plan.recurring_month + plan.notice_period_months = max(1, incoming_plan.notice_period_months) + plan.payment_period_months = max(1, incoming_plan.payment_period_months) else: session.add( ProductPlan( @@ -343,6 +363,8 @@ async def update_product( name=incoming_plan.name, cost_euro=Decimal(str(incoming_plan.cost_euro)), recurring_month=incoming_plan.recurring_month, + notice_period_months=max(1, incoming_plan.notice_period_months), + payment_period_months=max(1, incoming_plan.payment_period_months), ) ) @@ -472,6 +494,42 @@ async def list_customers(_: UserInfo = Depends(get_team_user), session=Depends(g ] +@router.post( + "/customers/{customer_uuid}/impersonation", + response_model=CustomerImpersonationResponse, +) +async def impersonate_customer_organization( + customer_uuid: UUID, + _: UserInfo = Depends(get_team_user), + session=Depends(get_database), +): + customer = session.query(Customer).filter_by(uuid=customer_uuid).first() + if not customer: + raise HTTPException(404, detail="Customer not found.") + + target_user = pick_organization_member_for_impersonation(customer) + if not target_user: + raise HTTPException( + 400, + detail="This organization has no users to impersonate.", + ) + + redirect_url = fetch_keycloak_impersonation_redirect_url(str(target_user.uuid)) + if not redirect_url: + raise HTTPException( + 503, + detail=( + "Impersonation is unavailable. Ensure IAM admin client credentials " + "are configured and the service account may impersonate users." + ), + ) + + return CustomerImpersonationResponse( + redirect_url=redirect_url, + target_user_email=target_user.email, + ) + + @router.get("/customers/{customer_uuid}", response_model=CustomerDetail) async def customer_detail( customer_uuid: UUID, @@ -512,17 +570,19 @@ async def customer_detail( } for invoice in customer.invoices ] + dto = serialize_customer(customer) return CustomerDetail( - uuid=customer.uuid, - name=customer.name, - email=customer.email, - website_url=customer.website_url, - phone_number=customer.phone_number, - city=customer.city, - country=customer.country, + uuid=dto.uuid, + name=dto.name, + email=dto.email, + website_url=dto.website_url, + phone_number=dto.phone_number, + registered_address=dto.registered_address, + invoice_address=dto.invoice_address, + parent_customer_uuid=dto.parent_customer_uuid, users=users, customer_products=customer_products, invoices=invoices, - created_at=customer.created_at, - updated_at=customer.updated_at, + created_at=dto.created_at, + updated_at=dto.updated_at, ) diff --git a/app/utils/app_variables/registry.py b/app/utils/app_variables/registry.py index 48f074e..70f8b16 100644 --- a/app/utils/app_variables/registry.py +++ b/app/utils/app_variables/registry.py @@ -9,6 +9,8 @@ class AppVariableKey(StrEnum): MAILING_INVITATION_TEMPLATE_ID = "mailing_invitation_template_id" + MAILING_CANCELLATION_TEMPLATE_ID = "mailing_cancellation_template_id" + MAILING_PRODUCT_BOOKED_TEMPLATE_ID = "mailing_product_booked_template_id" @dataclass(frozen=True) @@ -28,6 +30,20 @@ class VariableDefinition: description="Listmonk transactional template id for invitation emails.", default=None, ), + AppVariableKey.MAILING_CANCELLATION_TEMPLATE_ID: VariableDefinition( + key=AppVariableKey.MAILING_CANCELLATION_TEMPLATE_ID, + value_type="integer", + label="Cancellation mail template ID", + description="Listmonk transactional template id for product cancellation emails.", + default=None, + ), + AppVariableKey.MAILING_PRODUCT_BOOKED_TEMPLATE_ID: VariableDefinition( + key=AppVariableKey.MAILING_PRODUCT_BOOKED_TEMPLATE_ID, + value_type="integer", + label="Product booked confirmation mail template ID", + description="Listmonk transactional template id for product booking confirmation emails.", + default=None, + ), } diff --git a/app/utils/app_variables/store.py b/app/utils/app_variables/store.py index 79c8e70..d2deeed 100644 --- a/app/utils/app_variables/store.py +++ b/app/utils/app_variables/store.py @@ -60,6 +60,18 @@ def invitation_mail_template_id(self) -> int | None: return None return int(v) + def cancellation_mail_template_id(self) -> int | None: + v = self.get(AppVariableKey.MAILING_CANCELLATION_TEMPLATE_ID) + if v is None: + return None + return int(v) + + def product_booked_mail_template_id(self) -> int | None: + v = self.get(AppVariableKey.MAILING_PRODUCT_BOOKED_TEMPLATE_ID) + if v is None: + return None + return int(v) + def all_with_definitions(self) -> list[tuple[VariableDefinition, Any]]: rows = { r.key: r.value diff --git a/app/utils/config.py b/app/utils/config.py index 2056a14..5574eed 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -121,8 +121,8 @@ class Settings(BaseSettings): # IAM # ------------------------- IAM_BASE_URI: str = Field( - env="IAM_BASE_URL", - default="https://auth.example.com", + default="http://localhost:8080", + validation_alias=AliasChoices("IAM_BASE_URL", "IAM_BASE_URI"), ) IAM_REALM: str = Field(env="IAM_REALM", default="") IAM_CLIENT_REALM: str = Field(env="IAM_CLIENT_REALM", default="") diff --git a/app/utils/helpers/customer_product_status.py b/app/utils/helpers/customer_product_status.py new file mode 100644 index 0000000..423c2b8 --- /dev/null +++ b/app/utils/helpers/customer_product_status.py @@ -0,0 +1,66 @@ +from datetime import datetime +from uuid import UUID + +from sqlalchemy.orm import Session + +from models.customer_product import CustomerProduct +from models.invoice import Invoice +from models.static import CustomerProductStatusEnum, InvoiceStatusEnum + + +def primary_invoice_uuid_for_link(invoices: list[Invoice]) -> UUID | None: + if not invoices: + return None + overdue = [inv for inv in invoices if inv.status == InvoiceStatusEnum.OVERDUE] + if overdue: + return sorted(overdue, key=lambda inv: inv.date)[0].uuid + pending = [inv for inv in invoices if inv.status == InvoiceStatusEnum.PENDING] + if pending: + return sorted(pending, key=lambda inv: inv.date)[0].uuid + paid = [inv for inv in invoices if inv.status == InvoiceStatusEnum.PAID] + if paid: + return sorted(paid, key=lambda inv: inv.date)[-1].uuid + return invoices[0].uuid + + +def has_blocking_customer_product_booking( + session: Session, + *, + customer_uuid: UUID, + product_uuid: UUID, +) -> bool: + row = ( + session.query(CustomerProduct.uuid) + .filter( + CustomerProduct.customer_uuid == customer_uuid, + CustomerProduct.product_uuid == product_uuid, + CustomerProduct.status != CustomerProductStatusEnum.DEACTIVE, + ) + .first() + ) + return row is not None + + +def derive_invoice_status(invoices: list[Invoice]) -> InvoiceStatusEnum: + if not invoices: + return InvoiceStatusEnum.PAID + if any(inv.status == InvoiceStatusEnum.OVERDUE for inv in invoices): + return InvoiceStatusEnum.OVERDUE + if any(inv.status == InvoiceStatusEnum.PENDING for inv in invoices): + return InvoiceStatusEnum.PENDING + return InvoiceStatusEnum.PAID + + +def apply_canceled_to_deactive(session: Session, products: list[CustomerProduct]) -> None: + now = datetime.now() + changed = False + for cp in products: + if ( + cp.status == CustomerProductStatusEnum.CANCELED + and cp.cancellation_date is not None + and cp.cancellation_date <= now + ): + cp.status = CustomerProductStatusEnum.DEACTIVE + changed = True + if changed: + session.commit() diff --git a/app/utils/keycloak_iam.py b/app/utils/keycloak_iam.py index a8ccfed..5cd2bd0 100644 --- a/app/utils/keycloak_iam.py +++ b/app/utils/keycloak_iam.py @@ -1,10 +1,15 @@ +import logging +import os from typing import TYPE_CHECKING from keycloak import KeycloakAdmin +from keycloak.exceptions import KeycloakError if TYPE_CHECKING: from utils.config import Settings +logger = logging.getLogger(__name__) + def iam_keycloak_server_url(base_uri: str) -> str: base = (base_uri or "").strip().rstrip("/") @@ -63,3 +68,70 @@ def build_iam_keycloak_admin_from_settings( client_token_realm=token_realm or None, verify=verify, ) + + +def development_master_keycloak_admin() -> KeycloakAdmin | None: + from utils.config import settings as app_settings + + if not app_settings.DEVELOPMENT: + return None + password = (os.environ.get("KEYCLOAK_ADMIN_PASSWORD") or "").strip() + if not password: + return None + user = ( + os.environ.get("KEYCLOAK_ADMIN_USER") or os.environ.get("KEYCLOAK_ADMIN") or "admin" + ).strip() + server_url = iam_keycloak_server_url(app_settings.IAM_BASE_URI) + if not server_url: + return None + return KeycloakAdmin( + server_url=server_url, + username=user, + password=password, + realm_name="master", + verify=True, + ) + + +def try_fetch_customer_realm_keycloak_user( + settings: "Settings", + user_id: str, +) -> tuple[KeycloakAdmin, dict] | None: + from utils.config import main_realm_name + + admin = build_iam_keycloak_admin_from_settings(settings) + if admin is not None: + try: + return admin, admin.get_user(user_id) + except KeycloakError as exc: + if settings.DEVELOPMENT and exc.response_code in (401, 403): + logger.info( + "IAM Keycloak admin returned %s for get_user; trying master admin in development.", + exc.response_code, + ) + else: + raise + + if not settings.DEVELOPMENT: + return None + + master = development_master_keycloak_admin() + if master is None: + logger.warning( + "Master Keycloak admin not available; set KEYCLOAK_ADMIN_PASSWORD (and optionally " + "KEYCLOAK_ADMIN_USER or KEYCLOAK_ADMIN) to match docker-compose Keycloak bootstrap.", + ) + return None + realm = (main_realm_name or "").strip() + if not realm: + return None + try: + master.change_current_realm(realm) + return master, master.get_user(user_id) + except KeycloakError: + logger.exception( + "Master Keycloak admin could not load user %s in realm %s", + user_id, + realm, + ) + return None diff --git a/app/utils/keycloak_impersonation.py b/app/utils/keycloak_impersonation.py new file mode 100644 index 0000000..32715c7 --- /dev/null +++ b/app/utils/keycloak_impersonation.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import json +import logging +from collections.abc import Iterator + +from keycloak import KeycloakAdmin +from keycloak.exceptions import KeycloakError + +from models.customer import Customer +from models.static import RoleEnum +from models.user import User +from utils.config import main_realm_name, settings +from utils.keycloak_iam import ( + build_iam_keycloak_admin_from_settings, + development_master_keycloak_admin, +) + +logger = logging.getLogger(__name__) + + +def pick_organization_member_for_impersonation(customer: Customer) -> User | None: + members: list[User] = list(customer.users or []) + if not members: + return None + admins = [u for u in members if u.role == RoleEnum.ADMIN] + pool = admins if admins else members + return sorted(pool, key=lambda u: u.email.lower())[0] + + +def _iter_keycloak_admins_for_impersonation() -> Iterator[KeycloakAdmin]: + iam_admin = build_iam_keycloak_admin_from_settings(settings) + if iam_admin is not None: + yield iam_admin + master_admin = development_master_keycloak_admin() + if master_admin is not None: + yield master_admin + + +def _redirect_url_from_impersonation_response(response) -> str | None: + if response.status_code in (200, 201): + try: + payload = response.json() + except (json.JSONDecodeError, ValueError): + payload = None + if isinstance(payload, dict): + redirect = payload.get("redirect") + if isinstance(redirect, str) and redirect.strip(): + return redirect.strip() + if response.status_code in (302, 303, 307): + location = response.headers.get("Location") + if location: + return location + return None + + +def fetch_keycloak_impersonation_redirect_url(keycloak_user_id: str) -> str | None: + realm = (main_realm_name or "").strip() + if not realm: + logger.warning("Customer realm (OIDC_MAIN_ISSUER_URL) missing; cannot impersonate.") + return None + path = f"admin/realms/{realm}/users/{keycloak_user_id}/impersonation" + + admins = list(_iter_keycloak_admins_for_impersonation()) + if not admins: + logger.warning( + "No Keycloak admin credentials available. Configure IAM_CLIENT_SECRET with a " + "service account that may impersonate users, or in development set " + "KEYCLOAK_ADMIN_PASSWORD (and optionally KEYCLOAK_ADMIN_USER) for the master admin." + ) + return None + + last_status: int | None = None + for admin in admins: + try: + response = admin.connection.raw_post(path, data="{}") + except KeycloakError: + logger.exception("Keycloak impersonation request failed for user %s", keycloak_user_id) + continue + last_status = response.status_code + url = _redirect_url_from_impersonation_response(response) + if url: + return url + if response.status_code == 403: + logger.info( + "Impersonation returned 403 with one admin credential; retrying with another if configured." + ) + continue + logger.warning( + "Unexpected Keycloak impersonation response status=%s body=%s", + response.status_code, + (response.text or "")[:500], + ) + + if last_status == 403: + logger.warning( + "Impersonation forbidden (403). Grant the IAM service account the realm-management " + "impersonation role on the customer realm, or use master admin credentials in development." + ) + return None diff --git a/app/utils/keycloak_sync.py b/app/utils/keycloak_sync.py index c7882c1..bd1bc23 100644 --- a/app/utils/keycloak_sync.py +++ b/app/utils/keycloak_sync.py @@ -1,64 +1,54 @@ import logging from uuid import UUID as UUID4 -from keycloak import KeycloakAdmin from keycloak.exceptions import KeycloakError from utils.config import settings -from utils.keycloak_iam import build_iam_keycloak_admin_from_settings +from utils.keycloak_iam import try_fetch_customer_realm_keycloak_user logger = logging.getLogger(__name__) -def _keycloak_admin() -> KeycloakAdmin | None: - return build_iam_keycloak_admin_from_settings(settings) - - def append_customer_organization_to_keycloak_user( keycloak_user_id: UUID4 | str, customer_uuid: UUID4, ) -> bool: - admin = _keycloak_admin() uid = str(keycloak_user_id) org = str(customer_uuid) - if admin is None: + result = try_fetch_customer_realm_keycloak_user(settings, uid) + if result is None: logger.warning( - "IAM client not configured for Keycloak admin (IAM_BASE_URL, IAM_REALM, " - "IAM_CLIENT_ID, IAM_CLIENT_SECRET, optional IAM_CLIENT_REALM); skipping " - "organization attribute for user %s customer %s", + "Keycloak admin not available (configure IAM or KEYCLOAK_ADMIN_PASSWORD in development); " + "skipping organization attribute for user %s customer %s", uid, org, ) return False + admin, user_data = result try: - user_data = admin.get_user(uid) - except KeycloakError as exc: - logger.exception("Keycloak get_user failed for %s", uid) - raise exc - attributes = dict(user_data.get("attributes") or {}) - raw_orgs = attributes.get("organization", []) - if isinstance(raw_orgs, str): - orgs = [raw_orgs] if raw_orgs else [] - elif isinstance(raw_orgs, list): - orgs = list(raw_orgs) - else: - orgs = [] - if org not in orgs: - orgs.append(org) - attributes["organization"] = orgs - payload: dict = {"attributes": attributes} - for key in ( - "username", - "email", - "firstName", - "lastName", - "emailVerified", - ): - if user_data.get(key) is not None: - payload[key] = user_data[key] - if "enabled" in user_data: - payload["enabled"] = user_data["enabled"] - try: + attributes = dict(user_data.get("attributes") or {}) + raw_orgs = attributes.get("organization", []) + if isinstance(raw_orgs, str): + orgs = [raw_orgs] if raw_orgs else [] + elif isinstance(raw_orgs, list): + orgs = list(raw_orgs) + else: + orgs = [] + if org not in orgs: + orgs.append(org) + attributes["organization"] = orgs + payload: dict = {"attributes": attributes} + for key in ( + "username", + "email", + "firstName", + "lastName", + "emailVerified", + ): + if user_data.get(key) is not None: + payload[key] = user_data[key] + if "enabled" in user_data: + payload["enabled"] = user_data["enabled"] admin.update_user(uid, payload) except KeycloakError as exc: logger.exception("Keycloak update_user failed for %s", uid) diff --git a/app/utils/mailing/__init__.py b/app/utils/mailing/__init__.py index 68dc801..ab537aa 100644 --- a/app/utils/mailing/__init__.py +++ b/app/utils/mailing/__init__.py @@ -1,4 +1,20 @@ +from utils.mailing.booking import ( + build_product_booked_mail_data, + send_product_booked_mail, +) +from utils.mailing.cancellation import ( + build_product_cancellation_mail_data, + send_product_cancellation_mail, +) from utils.mailing.invitation import send_invitation_mail from utils.mailing.mailer import Mailer, mailer_from_settings -__all__ = ["Mailer", "mailer_from_settings", "send_invitation_mail"] +__all__ = [ + "Mailer", + "build_product_booked_mail_data", + "build_product_cancellation_mail_data", + "mailer_from_settings", + "send_invitation_mail", + "send_product_booked_mail", + "send_product_cancellation_mail", +] diff --git a/app/utils/mailing/booking.py b/app/utils/mailing/booking.py new file mode 100644 index 0000000..7bfbc56 --- /dev/null +++ b/app/utils/mailing/booking.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import logging +from collections.abc import Mapping +from datetime import datetime +from typing import Any + +from sqlalchemy.orm import Session + +from utils.app_variables import AppVariables +from utils.config import Settings +from utils.mailing.mailer import mailer_from_settings + +logger = logging.getLogger(__name__) + + +def _fmt_date(value: datetime | None) -> str: + if value is None: + return "" + if hasattr(value, "date"): + return value.date().isoformat() + return str(value) + + +def build_product_booked_mail_data( + *, + settings: Settings, + recipient_name: str, + organization_name: str, + product_name: str, + plan_name: str, + start_date: datetime, + seats: int | None, +) -> dict[str, Any]: + portal = settings.EXTERNAL_URL.rstrip("/") + invoices_url = settings.STRIPE_RETURN_URL + app_config: list[dict[str, str]] = [ + {"label": "Customer portal", "value": portal}, + {"label": "Invoices area", "value": invoices_url}, + ] + product_row: dict[str, Any] = { + "name": product_name, + "plan": plan_name, + "start_date": _fmt_date(start_date), + } + if seats is not None: + product_row["seats"] = seats + return { + "recipient_name": recipient_name, + "organization_name": organization_name, + "products": [product_row], + "app_config": app_config, + "portal_url": portal, + } + + +def send_product_booked_mail( + session: Session, + settings: Settings, + *, + to_email: str, + data: Mapping[str, Any] | None = None, +) -> Any: + mailer = mailer_from_settings(settings) + if not mailer: + logger.warning( + "Product booked confirmation mail skipped: mailing is not configured to_email=%s", + to_email, + ) + raise RuntimeError("Mailing is not configured.") + template_id = AppVariables(session).product_booked_mail_template_id() + if template_id is None: + logger.warning( + "Product booked confirmation mail skipped: template id is not set to_email=%s", + to_email, + ) + raise RuntimeError("Product booked confirmation mail template id is not set.") + logger.info( + "Product booked confirmation mail sending to_email=%s template_id=%s", + to_email, + template_id, + ) + return mailer.send_mail( + to_email=to_email, + template_id=template_id, + data=data, + ) diff --git a/app/utils/mailing/cancellation.py b/app/utils/mailing/cancellation.py new file mode 100644 index 0000000..b3d05ea --- /dev/null +++ b/app/utils/mailing/cancellation.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import logging +from collections.abc import Mapping +from datetime import datetime +from typing import Any + +from sqlalchemy.orm import Session + +from models.invoice import Invoice +from models.product_plan import ProductPlan +from models.static import InvoiceStatusEnum, PlanTypeEnum +from utils.app_variables import AppVariables +from utils.config import Settings +from utils.mailing.mailer import mailer_from_settings +from utils.scheduling.subscription_dates import ( + compute_next_cancellation_deadline, + compute_next_invoice_datetime, +) + +logger = logging.getLogger(__name__) + + +def _fmt_date(value: datetime | None) -> str: + if value is None: + return "" + if hasattr(value, "date"): + return value.date().isoformat() + return str(value) + + +def _fmt_money_eur(amount: object) -> str: + if amount is None: + return "—" + return f"{float(amount):.2f} €" + + +def build_product_cancellation_mail_data( + *, + settings: Settings, + organization_name: str, + product_name: str, + plan_name: str, + plan: ProductPlan, + product_start_date: datetime, + cancellation_requested_at: datetime, + subscription_end_date: datetime | None, + invoices: list[Invoice], +) -> dict[str, Any]: + now = cancellation_requested_at + pm = max(1, int(plan.payment_period_months or 1)) + nm = max(1, int(plan.notice_period_months or 1)) + ni: datetime | None = None + nc: datetime | None = None + if plan.type in (PlanTypeEnum.RECURRING, PlanTypeEnum.TRIAL): + ni = compute_next_invoice_datetime( + start_date=product_start_date, + payment_period_months=pm, + now=now, + ) + nc = compute_next_cancellation_deadline(ni, nm) + open_invoices = [ + inv + for inv in invoices + if inv.status in (InvoiceStatusEnum.PENDING, InvoiceStatusEnum.OVERDUE) + ] + open_rows: list[dict[str, str]] = [] + for inv in sorted(open_invoices, key=lambda x: x.date): + open_rows.append( + { + "title": inv.title or "—", + "date": _fmt_date(inv.date), + "amount": _fmt_money_eur(inv.total_amount), + "status": inv.status.value, + } + ) + future_pending = [ + inv + for inv in open_invoices + if inv.status == InvoiceStatusEnum.PENDING and inv.date > now + ] + deadlines: list[dict[str, str]] = [ + { + "label": "Cancellation recorded", + "value": _fmt_date(cancellation_requested_at), + }, + {"label": "Notice period", "value": f"{nm} month(s)"}, + ] + if subscription_end_date is not None: + deadlines.append( + { + "label": "Subscription / service end", + "value": _fmt_date(subscription_end_date), + } + ) + if ni is not None: + deadlines.append( + { + "label": "Next billing date (schedule)", + "value": _fmt_date(ni), + } + ) + if nc is not None: + deadlines.append( + { + "label": "Latest cancellation deadline (before next cycle)", + "value": _fmt_date(nc), + } + ) + portal = settings.EXTERNAL_URL.rstrip("/") + invoices_url = settings.STRIPE_RETURN_URL + app_config: list[dict[str, str]] = [ + {"label": "Customer portal", "value": portal}, + {"label": "Invoices area", "value": invoices_url}, + ] + has_scheduled_billing = ni is not None and plan.type in ( + PlanTypeEnum.RECURRING, + PlanTypeEnum.TRIAL, + ) + return { + "organization_name": organization_name, + "product_name": product_name, + "plan_name": plan_name, + "plan_type": plan.type.value, + "cancellation_requested_at": _fmt_date(cancellation_requested_at), + "deadlines": deadlines, + "open_invoices": open_rows, + "open_invoice_count": len(open_rows), + "has_open_invoices": len(open_rows) > 0, + "has_future_dated_pending_invoices": len(future_pending) > 0, + "has_scheduled_future_billing": bool( + has_scheduled_billing and ni is not None and ni > now + ), + "next_scheduled_billing_date": _fmt_date(ni) if ni else "", + "app_config": app_config, + "portal_url": portal, + } + + +def send_product_cancellation_mail( + session: Session, + settings: Settings, + *, + to_email: str, + data: Mapping[str, Any] | None = None, +) -> Any: + mailer = mailer_from_settings(settings) + if not mailer: + logger.warning( + "Product cancellation mail skipped: mailing is not configured to_email=%s", + to_email, + ) + raise RuntimeError("Mailing is not configured.") + template_id = AppVariables(session).cancellation_mail_template_id() + if template_id is None: + logger.warning( + "Product cancellation mail skipped: cancellation template id is not set to_email=%s", + to_email, + ) + raise RuntimeError("Cancellation mail template id is not set.") + logger.info( + "Product cancellation mail sending to_email=%s template_id=%s", + to_email, + template_id, + ) + return mailer.send_mail( + to_email=to_email, + template_id=template_id, + data=data, + ) diff --git a/app/utils/scheduling/__init__.py b/app/utils/scheduling/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/scheduling/subscription_dates.py b/app/utils/scheduling/subscription_dates.py new file mode 100644 index 0000000..4941481 --- /dev/null +++ b/app/utils/scheduling/subscription_dates.py @@ -0,0 +1,27 @@ +from datetime import datetime, time + +from dateutil.relativedelta import relativedelta + + +def compute_next_invoice_datetime( + start_date: datetime, + payment_period_months: int, + now: datetime, +) -> datetime: + pm = max(1, payment_period_months) + today = now.date() + k = 0 + while True: + cand = start_date + relativedelta(months=k * pm) + if cand.date() >= today: + return datetime.combine(cand.date(), time.min) + k += 1 + + +def compute_next_cancellation_deadline( + next_invoice_date: datetime, + notice_period_months: int, +) -> datetime: + nm = max(1, notice_period_months) + raw = next_invoice_date - relativedelta(months=nm) + return datetime.combine(raw.date(), time.min) diff --git a/app/utils/security/token.py b/app/utils/security/token.py index 2efb9a1..f0e0b1c 100644 --- a/app/utils/security/token.py +++ b/app/utils/security/token.py @@ -1,5 +1,6 @@ import json import time +from typing import NoReturn from uuid import UUID as UUID4 from fastapi import Depends, HTTPException, Request, status @@ -7,6 +8,7 @@ from keycloak import KeycloakOpenID from keycloak.exceptions import ( KeycloakAuthenticationError, + KeycloakConnectionError, KeycloakGetError, KeycloakPostError, ) @@ -197,6 +199,13 @@ def _build_user_info(user_info: dict) -> UserInfo: ) +def _identity_provider_unavailable(cause: Exception) -> NoReturn: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Identity provider unavailable", + ) from cause + + def _user_info_from_dev_fallback(openid: KeycloakOpenID, token: str) -> UserInfo: data = openid.userinfo(token) if not isinstance(data, dict): @@ -207,6 +216,8 @@ def _user_info_from_dev_fallback(openid: KeycloakOpenID, token: str) -> UserInfo def _user_info_from_userinfo(openid: KeycloakOpenID, token: str) -> UserInfo: try: return _user_info_from_dev_fallback(openid, token) + except KeycloakConnectionError as exc: + _identity_provider_unavailable(exc) except ( KeycloakPostError, KeycloakAuthenticationError, @@ -273,6 +284,8 @@ def _user_info_from_verified_jwt( def _user_info_from_introspection(openid: KeycloakOpenID, token: str) -> UserInfo: try: data = openid.introspect(token) + except KeycloakConnectionError as exc: + _identity_provider_unavailable(exc) except (KeycloakPostError, KeycloakAuthenticationError, KeycloakGetError) as exc: if openid is keycloak_openid: try: @@ -344,6 +357,8 @@ def verify_team(token: str) -> UserInfo: def verify_internal_token(token: str) -> InternalPrincipal: try: data = keycloak_openid_internal.introspect(token) + except KeycloakConnectionError as exc: + _identity_provider_unavailable(exc) except KeycloakPostError as exc: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -479,6 +494,8 @@ def authenticate_user(keycode: str, request: Request) -> str: scope="openid profile email organization", ) return token["access_token"] + except KeycloakConnectionError as exc: + _identity_provider_unavailable(exc) except KeycloakAuthenticationError as exc: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -500,6 +517,8 @@ def authenticate_team_user(keycode: str, request: Request) -> str: scope="openid profile email organization", ) return token["access_token"] + except KeycloakConnectionError as exc: + _identity_provider_unavailable(exc) except KeycloakAuthenticationError as exc: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ca0ed3e..00b6e01 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -20,13 +20,13 @@ services: - keycloak_db_data:/var/lib/postgresql/data keycloak: - image: quay.io/keycloak/keycloak + image: quay.io/keycloak/keycloak:26.4.7 command: - start-dev - --import-realm environment: - KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} - KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} + KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} KC_DB: postgres KC_DB_URL: jdbc:postgresql://keycloak-db:5432/${KEYCLOAK_DB_NAME:-keycloak} KC_DB_USERNAME: ${KEYCLOAK_DB_USER:-keycloak} diff --git a/flake.nix b/flake.nix index c9d4848..70e293f 100644 --- a/flake.nix +++ b/flake.nix @@ -72,9 +72,13 @@ export OIDC_TEAM_CLIENT_SECRET="team-backend" export IAM_BASE_URL="http://localhost:8080" - export IAM_REALM="master" - export IAM_CLIENT_ID="admin-cli" - export IAM_CLIENT_SECRET="" + export IAM_BASE_URI="http://localhost:8080" + export IAM_REALM="customer" + export IAM_CLIENT_ID="customer-internal" + export IAM_CLIENT_SECRET="customer-internal" + export KEYCLOAK_ADMIN_USER="admin" + export KEYCLOAK_ADMIN="admin" + export KEYCLOAK_ADMIN_PASSWORD="admin" export S3_ENDPOINT="http://localhost:9000" export S3_ACCESS_KEY="minioadmin" diff --git a/keycloak/README.md b/keycloak/README.md index 4f9f250..dcc4e1c 100644 --- a/keycloak/README.md +++ b/keycloak/README.md @@ -29,9 +29,17 @@ The script `keycloak/scripts/admin_cli.py` is used for administrative automation Required environment variables: - `IAM_BASE_URL` (default: `http://localhost:8080/`) -- `IAM_REALM` (default: `master`) -- `KEYCLOAK_ADMIN_USER` (default: `admin`) -- `KEYCLOAK_ADMIN_PASSWORD` (default: `admin`) +- `IAM_REALM` — must be the **customer** realm when calling user admin APIs for that realm (e.g. `customer`). +- `IAM_CLIENT_ID` / `IAM_CLIENT_SECRET` — confidential client with **service account** enabled (dev: `customer-internal` / `customer-internal` from `customer.json`). The service account needs **realm-management** roles that include **impersonation** if you rely on client credentials only. +- `KEYCLOAK_ADMIN_USER` (default: `admin`) / `KEYCLOAK_ADMIN_PASSWORD` (default: `admin`) — **master** realm admin; used by `admin_cli.py` and, in **development**, as a fallback for impersonation when the IAM service account is missing or lacks rights. Match `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` on the Keycloak container (see `docker-compose.dev.yml`). + +The Nix dev shell (`flake.nix`) exports IAM for `customer-internal` and admin-related variables so local Keycloak matches `docker-compose.dev.yml`. + +### `customer-internal` service account + +The backend uses client credentials against `customer-internal` to read and update users (organization attributes, etc.). In `customer.json`, the user `service-account-customer-internal` is imported with the **realm-management** client role **realm-admin** (see the `users` entry with `serviceAccountClientId` and `clientRoles`). That matches what you would capture by exporting the realm after assigning roles in the admin UI. + +In **development**, the app can still fall back to the **master** realm admin (`KEYCLOAK_ADMIN_USER` / `KEYCLOAK_ADMIN_PASSWORD`, aligned with `KC_BOOTSTRAP_ADMIN_*` on the Keycloak container) when the IAM client gets 401/403. Examples: diff --git a/keycloak/customer.json b/keycloak/customer.json index 58955da..ba6d0cb 100644 --- a/keycloak/customer.json +++ b/keycloak/customer.json @@ -461,6 +461,23 @@ "attributes" : { "organization" : [ "4de2a8b1-5c3f-4d11-a7e9-9b1c2d3e4f50", "5ef3b9c2-6d40-5e22-b8fa-0c2d3e4f5a61" ] } + }, { + "id" : "9a0c7f21-e8b4-4c1d-9f2a-7e8d9c0b1a2e", + "username" : "service-account-customer-internal", + "enabled" : true, + "serviceAccountClientId" : "customer-internal", + "createdTimestamp" : 1764546986930, + "totp" : false, + "credentials" : [ ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "default-roles-customer" ], + "clientRoles" : { + "realm-management" : [ "realm-admin" ] + }, + "notBefore" : 0, + "groups" : [ ], + "attributes" : { } } ], "scopeMappings" : [ { "clientScope" : "offline_access", diff --git a/web/locales/de-DE.arb b/web/locales/de-DE.arb index 41026e2..474a0ed 100644 --- a/web/locales/de-DE.arb +++ b/web/locales/de-DE.arb @@ -100,9 +100,28 @@ "workspaceProductsPanelDescription": "Aktive Abonnements und Tarife dieser Organisation.", "workspaceBookNew": "Produkt buchen", "workspaceBookAnother": "Buchen", + "workspaceProductCancelSubscription": "Kündigen", + "workspaceProductCancelDialogTitle": "Abonnement kündigen", + "workspaceProductCancelDialogDescription": "Hiermit kündigen Sie das Abonnement. Die Buchung ist gekündigt bis zum Kündigungsdatum, danach deaktiviert.", + "workspaceBookingStatusActive": "Aktiv", + "workspaceBookingStatusTrial": "Testphase", + "workspaceBookingStatusCanceled": "Gekündigt", + "workspaceBookingStatusDeactive": "Deaktiviert", "workspaceColumnProduct": "Produkt", "workspaceColumnPlan": "Tarif", "workspaceColumnStatus": "Status", + "workspaceColumnProductBooking": "Buchung", + "workspaceColumnProductService": "Service", + "workspaceColumnProductInvoice": "Rechnung", + "workspaceColumnCancellation": "Nächster Kündigungszeitpunkt", + "workspaceColumnNextInvoice": "Nächste Rechnung", + "workspaceInvoiceStatusOpen": "Offen", + "workspaceInvoiceStatusPaid": "Bezahlt", + "workspaceInvoiceStatusOverdue": "Überfällig", + "workspaceServiceStatusProvisioning": "Einrichtung", + "workspaceServiceStatusDegraded": "Eingeschränkt", + "workspaceServiceStatusOnline": "Online", + "workspaceServiceStatusDeprovisioning": "Abgebaut", "workspaceColumnStart": "Start", "workspaceColumnActions": "Aktionen", "workspaceNoRows": "Noch keine Eintrage.", @@ -176,7 +195,8 @@ "geocodeLoading": "Adressen werden gesucht…", "geocodeNoResults": "Keine passenden Adressen. Suche verfeinern.", "geocodeFailed": "Adresssuche fehlgeschlagen. Bitte erneut versuchen.", - "websiteReachabilityWarn": "Die Website konnte im Browser nicht gepruft werden. URL bitte prufen.", + "websiteReachabilityInvalid": "Diese Adresse antwortet nicht. URL prufen oder spater erneut versuchen.", + "websiteReachabilityVerified": "Website erreichbar", "validationWebsiteInvalid": "Bitte eine gultige http(s)-URL eingeben.", "validationPhoneInvalid": "Bitte eine gultige Telefonnummer (7–25 Zeichen, Ziffern und gangige Trennzeichen).", "validationCareOfTooLong": "c/o ist zu lang.", @@ -185,6 +205,11 @@ "validationPostalCodeInvalid": "Bitte eine gultige Postleitzahl (3–10 Zeichen).", "validationCityTooLong": "Stadt ist zu lang.", "validationCountryInvalid": "Bitte ein unterstutztes Land wahlen.", + "sectionCompanyAddress": "Firmenadresse", + "sectionInvoiceAddress": "Rechnungsadresse", + "invoiceAddressMatchesCompany": "Rechnungsadresse entspricht der Firmenadresse", + "fieldInvoiceAddressSearch": "Rechnungsadresse suchen", + "fieldInvoiceAddressSearchDescription": "Suche und wahle ein Ergebnis, um die Rechnungsadresse zu bestatigen. Es werden nur Adressen in freigeschalteten Landern angezeigt.", "sessionExpired": "Sitzung abgelaufen. Bitte erneut anmelden." } diff --git a/web/locales/en-US.arb b/web/locales/en-US.arb index 548391d..f314f77 100644 --- a/web/locales/en-US.arb +++ b/web/locales/en-US.arb @@ -100,9 +100,28 @@ "workspaceProductsPanelDescription": "Active subscriptions and plans linked to this organization.", "workspaceBookNew": "Book product", "workspaceBookAnother": "Book", + "workspaceProductCancelSubscription": "Cancel", + "workspaceProductCancelDialogTitle": "Cancel subscription", + "workspaceProductCancelDialogDescription": "This will cancel your subscription. The booking becomes canceled until the cancellation date, then deactivated.", + "workspaceBookingStatusActive": "Active", + "workspaceBookingStatusTrial": "Trial", + "workspaceBookingStatusCanceled": "Canceled", + "workspaceBookingStatusDeactive": "Deactivated", "workspaceColumnProduct": "Product", "workspaceColumnPlan": "Plan", "workspaceColumnStatus": "Status", + "workspaceColumnProductBooking": "Booking", + "workspaceColumnProductService": "Service", + "workspaceColumnProductInvoice": "Invoice", + "workspaceColumnCancellation": "Next cancellation date", + "workspaceColumnNextInvoice": "Next invoice", + "workspaceInvoiceStatusOpen": "Open", + "workspaceInvoiceStatusPaid": "Paid", + "workspaceInvoiceStatusOverdue": "Overdue", + "workspaceServiceStatusProvisioning": "Provisioning", + "workspaceServiceStatusDegraded": "Degraded", + "workspaceServiceStatusOnline": "Online", + "workspaceServiceStatusDeprovisioning": "Deprovisioning", "workspaceColumnStart": "Start", "workspaceColumnActions": "Actions", "workspaceNoRows": "No rows yet.", @@ -176,7 +195,8 @@ "geocodeLoading": "Searching addresses…", "geocodeNoResults": "No matching addresses. Refine your search.", "geocodeFailed": "Address search failed. Try again.", - "websiteReachabilityWarn": "Could not verify the website from the browser. Check that the URL is correct.", + "websiteReachabilityInvalid": "This address did not respond. Check the URL or try again later.", + "websiteReachabilityVerified": "Website reachable", "validationWebsiteInvalid": "Enter a valid http(s) URL.", "validationPhoneInvalid": "Enter a valid phone number (7–25 characters, digits and common separators).", "validationCareOfTooLong": "c/o is too long.", @@ -185,6 +205,11 @@ "validationPostalCodeInvalid": "Enter a valid postal code (3–10 characters).", "validationCityTooLong": "City is too long.", "validationCountryInvalid": "Select a supported country.", + "sectionCompanyAddress": "Company address", + "sectionInvoiceAddress": "Invoice address", + "invoiceAddressMatchesCompany": "Invoice address matches company address", + "fieldInvoiceAddressSearch": "Invoice address search", + "fieldInvoiceAddressSearchDescription": "Search and select a result to confirm the invoice billing address. Only addresses in enabled countries are shown.", "sessionExpired": "Session expired. Please sign in again." } diff --git a/web/package-lock.json b/web/package-lock.json index 27f3e27..6d3b2a7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@helpwave/hightide": "^0.9.5", "@tailwindcss/vite": "^4.1.18", + "lucide-react": "^0.561.0", "oidc-client-ts": "^3.5.0", "pdfjs-dist": "5.4.296", "react": "^19.2.4", diff --git a/web/package.json b/web/package.json index c7c6039..b3d9e3c 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,7 @@ "dependencies": { "@helpwave/hightide": "^0.9.5", "@tailwindcss/vite": "^4.1.18", + "lucide-react": "^0.561.0", "oidc-client-ts": "^3.5.0", "pdfjs-dist": "5.4.296", "react": "^19.2.4", diff --git a/web/src/api/admin.js b/web/src/api/admin.js index fabf4b3..5465ec3 100644 --- a/web/src/api/admin.js +++ b/web/src/api/admin.js @@ -16,6 +16,14 @@ export async function getAdminCustomerDetail(_token, customerUuid) { return apiRequest(`/team/admin/customers/${customerUuid}`, { token }) } +export async function postAdminCustomerImpersonation(_token, customerUuid) { + const token = await getTeamApiToken() + return apiRequest(`/team/admin/customers/${customerUuid}/impersonation`, { + token, + method: 'POST', + }) +} + export async function getAdminProducts(_token) { const token = await getTeamApiToken() return apiRequest('/team/admin/products', { token }) diff --git a/web/src/api/client.js b/web/src/api/client.js index ff06e19..9b9bbba 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -2,6 +2,9 @@ import { apiBaseUrl } from '../config/buildEnv' const reauthTriggerStatuses = new Set([401]) +const CUSTOMER_API_PATH_PREFIX = '/customer/' +const CUSTOMER_API_MAX_ATTEMPTS = 5 + export { apiBaseUrl } export class ApiRequestError extends Error { @@ -13,44 +16,145 @@ export class ApiRequestError extends Error { } } +export function shouldRetryCustomerApiFailure(status) { + if (status == null) { + return true + } + if (status >= 200 && status < 300) { + return false + } + if (status >= 300 && status < 400) { + return false + } + if (status >= 400 && status < 500) { + return false + } + return true +} + +function customerApiRetryDelayMs(attemptIndex) { + return Math.min(750 * 2 ** (attemptIndex - 1), 8000) +} + +function delay(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +function isCustomerApiPath(path) { + return typeof path === 'string' && path.startsWith(CUSTOMER_API_PATH_PREFIX) +} + +function isCustomerApiAbsoluteUrl(url) { + try { + const pathname = new URL(url).pathname + return pathname.startsWith(CUSTOMER_API_PATH_PREFIX) + } catch { + return false + } +} + +export async function fetchWithCustomerRetry(url, init) { + if (!isCustomerApiAbsoluteUrl(url)) { + return fetch(url, init) + } + + let lastError = null + for (let attempt = 1; attempt <= CUSTOMER_API_MAX_ATTEMPTS; attempt += 1) { + try { + const response = await fetch(url, init) + if ( + !response.ok && + shouldRetryCustomerApiFailure(response.status) && + attempt < CUSTOMER_API_MAX_ATTEMPTS + ) { + await delay(customerApiRetryDelayMs(attempt)) + continue + } + return response + } catch (err) { + lastError = err + if (attempt >= CUSTOMER_API_MAX_ATTEMPTS) { + throw err + } + const isAbort = + err instanceof DOMException && err.name === 'AbortError' + if (isAbort || !(err instanceof TypeError)) { + throw err + } + await delay(customerApiRetryDelayMs(attempt)) + } + } + throw lastError ?? new Error('Customer API request failed') +} + export async function apiRequest( path, { token, method = 'GET', body, headers = {} } = {}, ) { - const response = await fetch(`${apiBaseUrl}${path}`, { - method, - headers: { - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...(body ? { 'Content-Type': 'application/json' } : {}), - ...headers, - }, - ...(body ? { body: JSON.stringify(body) } : {}), - }) + const useCustomerRetry = isCustomerApiPath(path) + const maxAttempts = useCustomerRetry ? CUSTOMER_API_MAX_ATTEMPTS : 1 - if (!response.ok) { - const message = await response.text() - const error = new ApiRequestError( - message || `Request failed with status ${response.status}`, - { - status: response.status, - path, - }, - ) - if (reauthTriggerStatuses.has(response.status) && token) { - window.dispatchEvent( - new CustomEvent('helpwave:api-unauthorized', { - detail: { path, status: response.status }, - }), - ) - } - throw error - } + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const response = await fetch(`${apiBaseUrl}${path}`, { + method, + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...headers, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) - const contentType = response.headers.get('content-type') || '' - if (contentType.includes('application/json')) { - return response.json() - } + if (!response.ok) { + const message = await response.text() + const error = new ApiRequestError( + message || `Request failed with status ${response.status}`, + { + status: response.status, + path, + }, + ) + if ( + useCustomerRetry && + shouldRetryCustomerApiFailure(response.status) && + attempt < maxAttempts + ) { + await delay(customerApiRetryDelayMs(attempt)) + continue + } + if (reauthTriggerStatuses.has(response.status) && token) { + window.dispatchEvent( + new CustomEvent('helpwave:api-unauthorized', { + detail: { path, status: response.status }, + }), + ) + } + throw error + } - return response.text() + const contentType = response.headers.get('content-type') || '' + if (contentType.includes('application/json')) { + return response.json() + } + + return response.text() + } catch (err) { + const isAbort = + err instanceof DOMException && err.name === 'AbortError' + const isLikelyNetworkFailure = err instanceof TypeError + if ( + isAbort || + !useCustomerRetry || + attempt >= maxAttempts || + !isLikelyNetworkFailure + ) { + throw err + } + await delay(customerApiRetryDelayMs(attempt)) + } + } } diff --git a/web/src/api/customer.js b/web/src/api/customer.js index c1396a9..4d4377e 100644 --- a/web/src/api/customer.js +++ b/web/src/api/customer.js @@ -1,4 +1,4 @@ -import { apiBaseUrl, apiRequest } from './client' +import { apiBaseUrl, apiRequest, ApiRequestError, fetchWithCustomerRetry } from './client' export function checkCustomer(token) { return apiRequest('/customer/check/', { token }) @@ -44,6 +44,13 @@ export function getProducts() { return apiRequest('/customer/product/') } +export function getAvailableProducts(token, customerId) { + return apiRequest('/customer/product/available/', { + token, + headers: customerId ? { 'X-Customer-Id': customerId } : {}, + }) +} + export function getContractsByProducts(productUuids) { return apiRequest('/customer/contract/product/', { method: 'PUT', @@ -78,6 +85,14 @@ export function getCustomerProducts(token, customerId) { }) } +export function deleteCustomerProduct(token, customerId, productBookingUuid) { + return apiRequest(`/customer/product/${productBookingUuid}`, { + token, + method: 'DELETE', + headers: customerId ? { 'X-Customer-Id': customerId } : {}, + }) +} + export function getCustomerInvoices(token, customerId) { return apiRequest('/customer/invoice/self/', { token, @@ -95,18 +110,19 @@ export function payInvoice(token, customerId, invoiceUuid, locale = 'de') { } export async function downloadInvoiceFile(token, customerId, invoiceUuid) { - const response = await fetch( - `${apiBaseUrl}/customer/invoice/${invoiceUuid}/download`, - { - headers: { - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...(customerId ? { 'X-Customer-Id': customerId } : {}), - }, + const path = `/customer/invoice/${invoiceUuid}/download` + const response = await fetchWithCustomerRetry(`${apiBaseUrl}${path}`, { + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(customerId ? { 'X-Customer-Id': customerId } : {}), }, - ) + }) if (!response.ok) { const message = await response.text() - throw new Error(message || `Download failed (${response.status})`) + throw new ApiRequestError(message || `Download failed (${response.status})`, { + status: response.status, + path, + }) } return response.blob() } diff --git a/web/src/components/contract/ContractPdfViewer.jsx b/web/src/components/contract/ContractPdfViewer.jsx index 2b0024a..e91538e 100644 --- a/web/src/components/contract/ContractPdfViewer.jsx +++ b/web/src/components/contract/ContractPdfViewer.jsx @@ -1,7 +1,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { Document, Page } from 'react-pdf' -import { apiBaseUrl } from '../../api/client' +import { apiBaseUrl, fetchWithCustomerRetry } from '../../api/client' import 'react-pdf/dist/Page/AnnotationLayer.css' import 'react-pdf/dist/Page/TextLayer.css' @@ -67,7 +67,7 @@ export function ContractPdfViewer({ } const url = `${apiBaseUrl}/customer/contract/${contractId}/document` try { - const response = await fetch(url, { + const response = await fetchWithCustomerRetry(url, { headers: buildAuthHeaders(token, customerId), }) if (!response.ok) { diff --git a/web/src/components/dates/UpdatingDateTimeDisplay.jsx b/web/src/components/dates/UpdatingDateTimeDisplay.jsx new file mode 100644 index 0000000..472c4b6 --- /dev/null +++ b/web/src/components/dates/UpdatingDateTimeDisplay.jsx @@ -0,0 +1,17 @@ +import { useUpdatingDateString } from '@helpwave/hightide' + +function UpdatingDateTimeDisplayInner({ date, absoluteFormat }) { + const { absolute } = useUpdatingDateString({ date, absoluteFormat }) + return {absolute} +} + +export function UpdatingDateTimeDisplay({ date, absoluteFormat = 'dateTime' }) { + if (date == null || date === undefined) { + return + } + const d = date instanceof Date ? date : new Date(date) + if (Number.isNaN(d.getTime())) { + return + } + return +} diff --git a/web/src/components/icons/LucideIcons.jsx b/web/src/components/icons/LucideIcons.jsx index fb9985e..d8ca294 100644 --- a/web/src/components/icons/LucideIcons.jsx +++ b/web/src/components/icons/LucideIcons.jsx @@ -264,3 +264,13 @@ export function ArrowRightIcon() { ) } +export function VenetianMaskIcon() { + return ( + + + + + + ) +} + diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index c35a123..6fd626d 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -44,6 +44,8 @@ export type CustomerTranslationEntries = { 'fieldCountryPlaceholder': string, 'fieldEmail': string, 'fieldHouseNumber': string, + 'fieldInvoiceAddressSearch': string, + 'fieldInvoiceAddressSearchDescription': string, 'fieldName': string, 'fieldPhone': string, 'fieldPhoneDescription': string, @@ -56,6 +58,7 @@ export type CustomerTranslationEntries = { 'geocodeNoResults': string, 'goToCustomerArea': string, 'goToTeamAdminArea': string, + 'invoiceAddressMatchesCompany': string, 'language': string, 'loading': string, 'loadingAdminWorkspace': string, @@ -104,6 +107,8 @@ export type CustomerTranslationEntries = { 'required': string, 'saveMasterData': string, 'savingMasterData': string, + 'sectionCompanyAddress': string, + 'sectionInvoiceAddress': string, 'selectedOrganization': (values: { name: string }) => string, 'selectOrganization': string, 'sessionExpired': string, @@ -122,17 +127,27 @@ export type CustomerTranslationEntries = { 'validationPhoneInvalid': string, 'validationPostalCodeInvalid': string, 'validationWebsiteInvalid': string, - 'websiteReachabilityWarn': string, + 'websiteReachabilityInvalid': string, + 'websiteReachabilityVerified': string, 'workspaceAddMember': string, 'workspaceBookAnother': string, + 'workspaceBookingStatusActive': string, + 'workspaceBookingStatusCanceled': string, + 'workspaceBookingStatusDeactive': string, + 'workspaceBookingStatusTrial': string, 'workspaceBookNew': string, 'workspaceCancel': string, 'workspaceColumnActions': string, + 'workspaceColumnCancellation': string, 'workspaceColumnInvoice': string, 'workspaceColumnInvoiceDate': string, 'workspaceColumnJoined': string, + 'workspaceColumnNextInvoice': string, 'workspaceColumnPlan': string, 'workspaceColumnProduct': string, + 'workspaceColumnProductBooking': string, + 'workspaceColumnProductInvoice': string, + 'workspaceColumnProductService': string, 'workspaceColumnRole': string, 'workspaceColumnStart': string, 'workspaceColumnStatus': string, @@ -147,16 +162,26 @@ export type CustomerTranslationEntries = { 'workspaceInvoicesPanelDescription': string, 'workspaceInvoicesPanelTitle': string, 'workspaceInvoicesSubtitle': string, + 'workspaceInvoiceStatusOpen': string, + 'workspaceInvoiceStatusOverdue': string, + 'workspaceInvoiceStatusPaid': string, 'workspaceLoading': string, 'workspaceMemberEmail': string, 'workspaceNewTicket': string, 'workspaceNewTicketDescription': string, 'workspaceNoRows': string, + 'workspaceProductCancelDialogDescription': string, + 'workspaceProductCancelDialogTitle': string, + 'workspaceProductCancelSubscription': string, 'workspaceProductsPanelDescription': string, 'workspaceProductsPanelTitle': string, 'workspaceProductsSubtitle': string, 'workspaceReplyPlaceholder': string, 'workspaceSend': string, + 'workspaceServiceStatusDegraded': string, + 'workspaceServiceStatusDeprovisioning': string, + 'workspaceServiceStatusOnline': string, + 'workspaceServiceStatusProvisioning': string, 'workspaceSupportPanelDescription': string, 'workspaceSupportPanelTitle': string, 'workspaceSupportSubtitle': string, @@ -209,6 +234,8 @@ export const customerTranslation: Translation { return `Gewahlte Organisation: ${name}` }, @@ -295,17 +325,27 @@ export const customerTranslation: Translation { return `Selected organization: ${name}` }, @@ -468,17 +523,27 @@ export const customerTranslation: Translation {}, onPaginationChange: () => {}, onSortingChange: () => {}, pageCount: 1, + initialState: { + pagination: { + pageSize: 10, + }, + }, state: { columnFilters: [], pagination: { @@ -123,12 +134,93 @@ function useTeamToken() { return { teamUserInfo, teamToken } } +function formatAdminDateTime(value) { + if (!value) { + return '—' + } + try { + return new Date(value).toLocaleString() + } catch { + return String(value) + } +} + +function AdminPhoneLink({ value }) { + const text = typeof value === 'string' ? value.trim() : '' + if (!text) { + return '—' + } + return ( + + {text} + + ) +} + +function AdminMailtoLink({ value }) { + const text = typeof value === 'string' ? value.trim() : '' + if (!text) { + return '—' + } + return ( + + {text} + + ) +} + +function adminAddressLines(address) { + if (!address) { + return [] + } + const street = [address.address, address.house_number].filter(Boolean).join(' ') + const lines = [] + if (street) { + lines.push(street) + } + if (address.care_of) { + lines.push(`c/o ${address.care_of}`) + } + const cityLine = [address.postal_code, address.city].filter(Boolean).join(' ') + if (cityLine) { + lines.push(cityLine) + } + if (address.country) { + lines.push(address.country) + } + return lines +} + +function AdminAddressReadout({ title, lines }) { + return ( +
+

+ {title} +

+ {lines.length === 0 ? ( +

+ ) : ( + lines.map((line, index) => ( +

+ {line} +

+ )) + )} +
+ ) +} + export function AdminCustomersPage() { const { teamToken } = useTeamToken() const [customers, setCustomers] = useState([]) const [selectedCustomerUuid, setSelectedCustomerUuid] = useState('') const [customerDetail, setCustomerDetail] = useState(null) const [isLoadingCustomers, setIsLoadingCustomers] = useState(false) + const [impersonationError, setImpersonationError] = useState('') + const [impersonationBusyUuid, setImpersonationBusyUuid] = useState('') useEffect(() => { if (!teamToken) { @@ -152,9 +244,36 @@ export function AdminCustomersPage() { .catch(() => setCustomerDetail(null)) }, [selectedCustomerUuid, teamToken]) + useEffect(() => { + setImpersonationError('') + }, [selectedCustomerUuid]) + + const startCustomerImpersonation = async (customerUuid) => { + if (!customerUuid) { + return + } + setImpersonationError('') + setImpersonationBusyUuid(customerUuid) + try { + const result = await postAdminCustomerImpersonation(teamToken, customerUuid) + if (result?.redirect_url) { + window.location.assign(result.redirect_url) + return + } + setImpersonationError('Impersonation did not return a redirect URL.') + } catch (error) { + setImpersonationError(String(error?.message || error)) + } finally { + setImpersonationBusyUuid('') + } + } + return ( - + {isLoadingCustomers || customers.length === 0 ? ( ) : ( - - - - - { + const name = row.original.name + return ( + + {name} + + ) + }} + /> + { + const email = row.original.email + return ( + + {email} + + ) + }} + /> + + + + ( - - )} + minSize={96} + size={104} + maxSize={128} + cell={({ row }) => { + const uuid = row.original.uuid + const busy = impersonationBusyUuid === uuid + const noUsers = row.original.users_count === 0 + return ( +
+ + + + + + +
+ ) + }} />
)} + {impersonationError ? ( +

{impersonationError}

+ ) : null}
- + {customerDetail ? ( -
-

Name: {customerDetail.name}

-

Email: {customerDetail.email}

-

Website: {customerDetail.website_url || '-'}

-

Users: {customerDetail.users.length}

-

Bookings: {customerDetail.customer_products.length}

-

Invoices: {customerDetail.invoices.length}

+
+
+
+

+ Organization +

+
+
+ Name + + {customerDetail.name} + +
+
+ Email + + + +
+
+ Phone + + + +
+
+ Website + + {customerDetail.website_url ? ( + + {customerDetail.website_url} + + ) : ( + '—' + )} + +
+
+ Parent organization + + {customerDetail.parent_customer_uuid || '—'} + +
+
+ Created + {formatAdminDateTime(customerDetail.created_at)} +
+
+ Updated + {formatAdminDateTime(customerDetail.updated_at)} +
+
+
+ +
+

+ Addresses +

+
+ + {customerDetail.invoice_address ? ( + + ) : ( +

+ Invoice address matches the registered address. +

+ )} +
+
+
+ +
+
+

+ Users ({customerDetail.users.length}) +

+ {customerDetail.users.length === 0 ? ( +

No users.

+ ) : ( +
    + {customerDetail.users.map((row) => ( +
  • + {row.name} + — {row.email} + ({row.role}) +
  • + ))} +
+ )} +
+
+

+ Summary +

+
+

+ Product bookings: + + {customerDetail.customer_products.length} + +

+

+ Invoices: + + {customerDetail.invoices.length} + +

+
+
+
) : (

Select a customer row to open details.

@@ -439,7 +772,16 @@ export function AdminProductsPage() { description: '', image_url: '', contract_uuids: [], - plans: [{ type: 'recurring', name: '', cost_euro: 0, recurring_month: 1 }], + plans: [ + { + type: 'recurring', + name: '', + cost_euro: 0, + recurring_month: 1, + notice_period_months: 1, + payment_period_months: 1, + }, + ], }) const resetProductForm = () => { setProductForm({ @@ -448,7 +790,16 @@ export function AdminProductsPage() { description: '', image_url: '', contract_uuids: [], - plans: [{ type: 'recurring', name: '', cost_euro: 0, recurring_month: 1 }], + plans: [ + { + type: 'recurring', + name: '', + cost_euro: 0, + recurring_month: 1, + notice_period_months: 1, + payment_period_months: 1, + }, + ], }) } const refresh = useCallback(() => { @@ -485,6 +836,8 @@ export function AdminProductsPage() { ...plan, cost_euro: Number(plan.cost_euro), recurring_month: plan.recurring_month ? Number(plan.recurring_month) : null, + notice_period_months: Math.max(1, Number(plan.notice_period_months) || 1), + payment_period_months: Math.max(1, Number(plan.payment_period_months) || 1), })), } if (editingProduct) { @@ -564,8 +917,21 @@ export function AdminProductsPage() { contract_uuids: product.contract_uuids || [], plans: product.plans.length > 0 - ? product.plans - : [{ type: 'recurring', name: '', cost_euro: 0, recurring_month: 1 }], + ? product.plans.map((p) => ({ + ...p, + notice_period_months: p.notice_period_months ?? 1, + payment_period_months: p.payment_period_months ?? 1, + })) + : [ + { + type: 'recurring', + name: '', + cost_euro: 0, + recurring_month: 1, + notice_period_months: 1, + payment_period_months: 1, + }, + ], }) setSelectedPlanIndex(null) setIsProductDrawerOpen(true) @@ -686,7 +1052,14 @@ export function AdminProductsPage() { ...state, plans: [ ...state.plans, - { type: 'recurring', name: '', cost_euro: 0, recurring_month: 1 }, + { + type: 'recurring', + name: '', + cost_euro: 0, + recurring_month: 1, + notice_period_months: 1, + payment_period_months: 1, + }, ], })) setSelectedPlanIndex(productForm.plans.length) @@ -791,6 +1164,42 @@ export function AdminProductsPage() { })) } /> + + setProductForm((state) => ({ + ...state, + plans: state.plans.map((entry, entryIndex) => + entryIndex === selectedPlanIndex + ? { + ...entry, + notice_period_months: value ? Math.max(1, Number(value)) : 1, + } + : entry, + ), + })) + } + /> + + setProductForm((state) => ({ + ...state, + plans: state.plans.map((entry, entryIndex) => + entryIndex === selectedPlanIndex + ? { + ...entry, + payment_period_months: value ? Math.max(1, Number(value)) : 1, + } + : entry, + ), + })) + } + />