Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Process payment, balance endpoint #71

Merged
merged 14 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion env-prep/docker-compose-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,6 @@ services:
networks:
- ls


blazegraph:
image: bluebrain/blazegraph-nexus:2.1.6-RC
environment:
Expand All @@ -172,3 +171,23 @@ services:
- delta_data:/var/lib/postgresql/data
networks:
- ls

stripe-cli:
image: stripe/stripe-cli
container_name: stripe
environment:
STRIPE_CLI_TELEMETRY_OPTOUT: 1
STRIPE_API_KEY: ${STRIPE_SECRET_KEY}
STRIPE_DEVICE_NAME: ${STRIPE_DEVICE_NAME}
volumes:
- ./stripe-data:/stripe-data
entrypoint:
- '/bin/sh'
- '-c'
- |
echo STRIPE_WEBHOOK_SECRET=$(stripe listen --print-secret) > /stripe-data/.env.local &&
stripe listen --forward-to http://host.docker.internal:8000/payments/webhook
network_mode: host
extra_hosts:
- "host.docker.internal:host-gateway"
- "172.17.0.1:host-gateway"
21 changes: 20 additions & 1 deletion virtual_labs/domain/payment_method.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from datetime import datetime
from typing import List, Optional
from typing import List, Literal, Optional

from pydantic import UUID4, BaseModel, ConfigDict, EmailStr

Expand Down Expand Up @@ -29,6 +29,25 @@ class PaymentMethodOut(BaseModel):
payment_method: PaymentMethod


class StripePaymentOut(BaseModel):
virtual_lab_id: UUID4
status: Literal[
"canceled",
"processing",
"requires_action",
"requires_capture",
"requires_confirmation",
"requires_payment_method",
"succeeded",
]


class VlabBalanceOut(BaseModel):
virtual_lab_id: UUID4
budget: float
total_spent: float


class PaymentMethodDeletionOut(BaseModel):
virtual_lab_id: UUID4
payment_method_id: UUID4
Expand Down
12 changes: 11 additions & 1 deletion virtual_labs/repositories/billing_repo.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import List, cast

from pydantic import UUID4
from sqlalchemy import delete, select, update
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import and_

Expand Down Expand Up @@ -43,6 +43,14 @@ async def retrieve_vl_payment_methods(
payment_cards = [row for row in result]
return payment_cards

async def retrieve_payment_methods_count(self, lab_id: UUID4) -> int | None:
result = await self.session.scalar(
select(func.count(PaymentMethod.id)).where(
PaymentMethod.virtual_lab_id == lab_id,
)
)
return result


class BillingMutationRepository:
session: AsyncSession
Expand All @@ -61,6 +69,7 @@ async def add_new_payment_method(
brand: str,
cardholder_name: str,
cardholder_email: str,
default: bool = False,
) -> PaymentMethod:
payment_method = PaymentMethod(
user_id=user_id,
Expand All @@ -71,6 +80,7 @@ async def add_new_payment_method(
card_number=card_number,
brand=brand,
expire_at=expire_at,
default=default,
)

self.session.add(payment_method)
Expand Down
6 changes: 5 additions & 1 deletion virtual_labs/repositories/labs.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
from virtual_labs.core.types import PaginatedDbResult
from virtual_labs.domain import labs
from virtual_labs.domain.common import PageParams
from virtual_labs.infrastructure.db.models import Project, VirtualLab, VirtualLabTopup
from virtual_labs.infrastructure.db.models import (
Project,
VirtualLab,
VirtualLabTopup,
)


class VirtualLabDbCreate(labs.VirtualLabCreate):
Expand Down
120 changes: 101 additions & 19 deletions virtual_labs/routes/billing.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from textwrap import dedent
from typing import Annotated, Tuple

from fastapi import APIRouter, Body, Depends
Expand All @@ -16,6 +17,8 @@
PaymentMethodOut,
PaymentMethodsOut,
SetupIntentOut,
StripePaymentOut,
VlabBalanceOut,
)
from virtual_labs.infrastructure.db.config import default_session_factory
from virtual_labs.infrastructure.kc.auth import verify_jwt
Expand Down Expand Up @@ -67,30 +70,13 @@ async def add_new_payment_method_to_vl(
)


@router.post(
"/{virtual_lab_id}/billing/setup-intent",
operation_id="generate_setup_intent",
summary="generate setup intent for a specific stripe customer (customer == virtual lab)",
response_model=VliAppResponse[SetupIntentOut],
)
@verify_vlab_write
async def generate_setup_intent(
virtual_lab_id: UUID4,
session: AsyncSession = Depends(default_session_factory),
auth: Tuple[AuthUser, str] = Depends(verify_jwt),
) -> Response:
return await billing_cases.generate_setup_intent(
session,
virtual_lab_id=virtual_lab_id,
auth=auth,
)


@router.patch(
"/{virtual_lab_id}/billing/payment-methods/default",
operation_id="update_default_payment_method",
summary="""
Update default payment method
""",
description="""
This will be used only for stripe invoice and subscription.
for paymentIntent you have to pass the payment method Id
""",
Expand Down Expand Up @@ -130,3 +116,99 @@ async def delete_payment_method(
payment_method_id=payment_method_id,
auth=auth,
)


@router.post(
"/{virtual_lab_id}/billing/setup-intent",
operation_id="generate_setup_intent",
summary="generate setup intent for a specific stripe customer (customer == virtual lab)",
description=dedent(
"""
This endpoint will only generate the setup intent, to be able to use it correctly in attaching
the payment method to a specific virtual lab, you have to confirm it.

To confirm the setup intent without using the frontend app, you can access the stripe api docs
and use the builtin-CLI to confirm the setup intent id.

### Stripe dashboard CLI
You have to use test mode of the stripe account
[Stripe Builtin-CLI](https://docs.stripe.com/api/setup_intents/confirm?shell=true&api=true&resource=setup_intents&action=confirm)

### Local machine Stripe CLI

```shell
stripe setup_intents confirm {setup_intent_id} --payment-method={payment_method}
```
where:
```py
setup_intent_id = `seti_1Mm2cBLkdIwHu7ixaiKW3ElR` # the generated setupIntent
payment_method = `pm_card_visa`
# it can be any payment method, available in test cards page
```

[Stripe Test cards](https://docs.stripe.com/testing?testing-method=payment-methods)
"""
),
response_model=VliAppResponse[SetupIntentOut],
)
@verify_vlab_write
async def generate_setup_intent(
virtual_lab_id: UUID4,
session: AsyncSession = Depends(default_session_factory),
auth: Tuple[AuthUser, str] = Depends(verify_jwt),
) -> Response:
return await billing_cases.generate_setup_intent(
session,
virtual_lab_id=virtual_lab_id,
auth=auth,
)


@router.post(
"/{virtual_lab_id}/billing/budget-topup",
operation_id="init_vl_budget_topup",
summary="""
init virtual lab adding new budget amount processing
""",
response_model=VliAppResponse[StripePaymentOut],
)
@verify_vlab_write
async def init_vl_budget_topup(
virtual_lab_id: UUID4,
payment_method_id: Annotated[UUID4, Body(embed=True)],
credit: Annotated[float, Body(embed=True)],
session: AsyncSession = Depends(default_session_factory),
auth: Tuple[AuthUser, str] = Depends(verify_jwt),
) -> Response:
return await billing_cases.init_vl_budget_topup(
session,
virtual_lab_id=virtual_lab_id,
payment_method_id=payment_method_id,
credit=credit,
auth=auth,
)


@router.get(
"/{virtual_lab_id}/billing/balance",
operation_id="retrieve_virtual_lab_balance",
summary="""
bilalesi marked this conversation as resolved.
Show resolved Hide resolved
retrieve current balance (budget, total_spent) for a virtual lab
""",
description=dedent(
"""
**The total spent is dummy value for the moment (waiting for the dedicated service to be ready)**
"""
),
response_model=VliAppResponse[VlabBalanceOut],
)
@verify_vlab_read
async def retrieve_virtual_lab_balance(
virtual_lab_id: UUID4,
session: AsyncSession = Depends(default_session_factory),
auth: Tuple[AuthUser, str] = Depends(verify_jwt),
) -> Response:
return await billing_cases.retrieve_virtual_lab_balance(
session,
virtual_lab_id=virtual_lab_id,
)
36 changes: 36 additions & 0 deletions virtual_labs/tests/billing/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,39 @@ async def mock_create_payment_methods(
)

yield virtual_lab, payment_methods, headers


@pytest_asyncio.fixture
async def mock_create_payment_method(
async_test_client: AsyncClient,
mock_lab_create: tuple[Response, dict[str, str]],
) -> AsyncGenerator[tuple[dict[str, str], dict[str, str], dict[str, str]], None]:
client = async_test_client
response, headers = mock_lab_create

virtual_lab = response.json()["data"]["virtual_lab"]
virtual_lab_id = response.json()["data"]["virtual_lab"]["id"]

async with session_context_factory() as session:
customer_id = (
await session.execute(
statement=select(VirtualLab.stripe_customer_id).filter(
VirtualLab.id == UUID(virtual_lab_id)
)
)
).scalar_one()

payment_method = {
"name": "test one payment_method",
"email": "[email protected]",
"setupIntentId": (await create_confirmed_setup_intent(customer_id)).id,
}

response = await client.post(
f"/virtual-labs/{virtual_lab_id}/billing/payment-methods",
json=payment_method,
)

payment_method = response.json()["data"]["payment_method"]

yield virtual_lab, payment_method, headers
26 changes: 26 additions & 0 deletions virtual_labs/tests/billing/test_init_payment_process.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import pytest
from httpx import AsyncClient

new_credit = 5000 # in dollars


@pytest.mark.asyncio
async def test_init_payment_process(
async_test_client: AsyncClient,
mock_create_payment_method: tuple[dict[str, str], dict[str, str], dict[str, str]],
) -> None:
client = async_test_client
virtual_lab, payment_method, headers = mock_create_payment_method
virtual_lab_id = virtual_lab["id"]

payment_method_id = payment_method["id"]
response = await client.post(
f"/virtual-labs/{virtual_lab_id}/billing/budget-topup",
json={
"payment_method_id": payment_method_id,
"credit": new_credit,
},
)

assert response is not None
assert response.status_code == 200
Loading