Skip to content

Payment Method Component

A global component to manage payment methods of both companies and members.

Purpose and responsibility

  • act as a single source of truth for all payment methods
  • encapsulate logic to create and interact with payment methods and associated documents
  • encapsulate models to store payment methods and associated documents
  • manage the lifecycle of payment methods (creation, expiration, suspension, etc...)
  • keep the payment method in sync with the remote representation on the platforms of PSPs (payment service providers)

Key Concepts

Billing Customer

A BillingCustomer represents a member or a company paying for Alan services.
It is meant to be 'profile-compatible': if the customer is a member they are linked by the profile_id property. One of this entity's main responsibilities is to house properties used to maintain the relationship with PSPs.

Payment Methods

A billing customer may have several payment methods. BillingPaymentMethod is a polymorphic object, with each variant representing a specific payment method type: payment card, SEPA direct debit, etc... It contains properties representing the underlying banking information (last 4 digits of card number, IBAN, etc...) as well as properties to link it to its representation on PSPs.

State of this component

This component is not ready for public use.

It is still under development and is not ready for public use.
While it is global-first and can be used in every country almost out of the box, in practice replacing every existing usage of local payment method models takes time.
It is possible for both local and global models to co-exist but it requires extra development, which is why most countries are marked as unimplemented. Additionally, some configuration for legal documents is required.

See Migration strategy

If you are unsure about how to use this component, or whether it is ready for your specific use-case, please reach out.

Country SEPA DD Card Members Companies Multi PM Shop, etc... Stripe
France πŸ‡«πŸ‡· ❌ ❌ ❌ ❌ ❌ ❌ ❌
Belgium πŸ‡§πŸ‡ͺ βœ… ❌ βœ… ❌ ❌ ❌ ❌
Spain πŸ‡ͺπŸ‡Έ ❌ ❌ ❌ ❌ ❌ ❌ ❌
Canada πŸ‡¨πŸ‡¦ ❌ ❌ ❌ ❌ ❌ ❌ ❌

Usage

The public API of the component is exposed through a service class called PaymentMethodService. All functions are available as static methods of this class.
For example, to get the BillingCustomer for a given member:

from components.payment_method.public.api import PaymentMethodService

PaymentMethodService.get_billing_customer(profile_id=user.profile_id)

The component also exposes an HTTP API: /api/billing/v2/billing_customer.
This API is mostly used by companion flows on the frontend: Payment Method Selector, Payment Hub, etc ...
For more details on the HTTP API, read the controller implementation.

Creating SEPA direct debit payment methods

When creating payment methods, the billing customer is automatically created on the fly if it does not already exist.
There are two main creation scenarios:

  1. The creation process is owned and orchestrated by PaymentMethodService
  2. The creation is owned by some component outside of PaymentMethodService

When creation is owned by PaymentMethodService

Every step of the process is taken care of by the component:

  • SEPA mandate generation from a legally compliant template and the personal information of the profile's owner
  • Signature link generation using Dropbox sign
  • Creating the payment method and attaching the SEPA mandate upon receiving the signature completion event
  • Automatically marking the profile's owner as the signer

This is used when a payment method is created from Payment Method Selector.

from shared.helpers.app_name import AppName
from shared.signed_document.hellosign_event_type import HellosignEventType
from shared.signed_document.enums.signature_type import SignatureType
from components.payment_method.public.api import PaymentMethodService

# 1. Create a signature request on Dropbox Sign
signature_request = (
    PaymentMethodService.create_sepa_mandate_signature_request(
        app_name=AppName.ALAN_BE,
        iban="DE89370400440532013000",
        profile_id=profile_id
    )
)

# 2. Extract the signature URL and open it in a browser so the user can sign the SEPA mandate
sign_url = signature_request["sign_url"]

# 3. Listen for events comming from Dropbox Sign
event = server.listen()

# 4. Redirect the event to PaymentMethodService
if (
    event.metadata.get("origin_component") == "PaymentMethodService"
    and event.signature_type == SignatureType.sepa_mandate
    and event.event_type == (
        HellosignEventType.signature_request_all_signed
    )
):
    customer, payment_method = (
        PaymentMethodService.handle_hellosign_callback(
            event=event,
            app_name=AppName.ALAN_BE
        )
    )

When creation is owned by some other component

You may chose to orchestrate your own flow for retrieving all the necessary data and signing the document.
In which case you would only use PaymentMethodService to store all this information.
You are responsible for making sure the information passed is correct and the SEPA mandate is legally compliant.

This happens when migrating existing local flows, or when creation of the payment method is a side effect of your main process (example: proposal builder).

from shared.signed_document.models.signed_bundle import Signer
from components.payment_method.public.api import PaymentMethodService


# 1. Create a SepaDirectDebitPaymentMethod
customer, payment_method = (
    PaymentMethodService.create_sepa_direct_debit_payment_method(
        profile_id=user.profile_id,
        iban="DE89370400440532013000",
    )
)

# 2. Attach the signed SEPA mandate your collected
customer, payment_method = (
    PaymentMethodService.attach_signed_debit_mandate_to_sepa_direct_debit_payment_method(
        payment_method_id=pm.id,
        mandate_info={
            "uri": "s3://alan-documents/mandates/signed-mandate-789012.pdf",
            "unique_reference": "MANDATE789012",
            "signer": Signer(
                email="john@example.com",
                first_name="John",
                last_name="Doe"
            )
        }
    )
)

Getting the main payment method of a billing customer

Customers can have one active payment method of each type

This means that you can simply retrieve the billing customer and if they have a payment method you can assume it is their main payment method

from shared.helpers.collections import first_or_none
from components.payment_method.public.api import PaymentMethodService

customer = PaymentMethodService.get_billing_customer(
    profile_id=user.profile_id
)

pm = first_or_none(customer.sepa_debit_payment_methods)

if pm:
    print(pm.is_chargeable) # Ex: tuple[False, UnchargeableReason.NO_MANDATE]

Transaction management

Migration strategy

Alternative documentation available

Another version of this explanation is available on Notion β§‰.

Each country component implements its own IBAN model based on CoreIBAN: BeIBAN, EsIBAN, etc.
Moving to a global-first approach requires stopping reliance on these local models.
However, they are deeply integrated into existing country components:

  • 66 occurrences of IBAN in components/fr
  • 23 occurrences of BeIBAN in components/be
  • 21 occurrences of EsIBAN in components/es

Source of these numbers

These counts use regex to identify type hints, imports, and instantiations (excluding test files).
Some occurrences are related to claims rather than billing.
Additional usage likely exists in other components.

Migration approach

Migrating in one-shot replacing all the code using local models to use PaymentMethodService is unrealistic due to:

  • Time intensity
  • Risk of relying 100% on a new component that was never battle tested
  • Risks inherent to large rewrites

We pick a gradual migration path, giving us more flexibility:

  • Replace local model usage incrementally, one call-site at a time
  • Provide globalization benefits without upfront refactoring
  • Apply consistently across all countries with minimal adaptation

We chose to write and read from both local and global tables simultaneously.

This design enables incremental migration in a pretty elegant way. When you write new code using PaymentMethodService, it immediately benefits from global functionality while automatically keeping existing local code working through dual writes. Existing code using local models just keeps working unchanged during the migration - you don't need to refactor anything upfront. Mixed usage scenarios work seamlessly because new global code can read data created by old local code, and vice versa, through the fallback read strategy.

Here's what happens for write operations in Belgium - when you call PaymentMethodService.create_sepa_direct_debit_payment_method(...):

  • one row will be added to BillingCustomerModel (global model)
  • one row will be added to SepaDirectDebitPaymentMethodModel (global model)
  • one row will be added to BeIBAN (local model)

And for read operations in Belgium - when you call PaymentMethodService.get_billing_customer(profile_id=user.profile_id):

  • first, we will read from BillingCustomerModel filtering by profile_id (global model)
  • if we do not find anything in global tables, then we fallback to a read of BeUser filtering by profile_id (local model)

This dual approach means that each call-site can be migrated individually without breaking existing functionality, while immediately providing the benefits of the global component to any code that chooses to use it.

Implementation patterns

Two patterns enable this dual-table approach:

  • Repository pattern
  • Clear separation between business entities and data models

These patterns are uncommon at Alan, because we usually try to avoid abstraction. Because they are uncommon, this README includes small introductions.
Note that these patterns are temporary migration tools. Once all countries use the payment method component as their source of truth, these abstractions can be removed to reduce complexity.

The repository pattern

The repository pattern isolates domain use-cases from infrastructure concerns.
Consider this simple e-commerce function:

def place_order(client, items):
    validate_items(items)
    send_success_notification(client)

    order = {"items": items, "total": sum(items)}
    save_order(order)
    return order

This function's responsibilities are validating items, notifying the client, and storing the order.
The domain logic doesn't care where the order is savedβ€”only that it's saved.
The storage location (database, file system, object storage) is an infrastructure detail.

The repository pattern achieves this separation by:

  • Representing infrastructure concerns through interfaces
  • Injecting concrete implementations when calling use-cases
class OrderRepo(Protocol):
    def save(order): ...

class FileOrderRepo(OrderRepo):
    def save(order):
        file.write(...)

class SQLOrderRepo(OrderRepo):
    def save(order):
        session.add(OrderModel.from_dict(order))

def place_order(items, order_repo):
    validate_items(items)
    send_success_notification(client)

    order = {"items": items, "total": sum(items)}
    order_repo.save(order)
    return order

place_order([...], FileOrderRepo()) # Writes to file
place_order([...], SQLOrderRepo()) # Writes to SQL database

# In both cases, the business logic that is executed is the same
# The only difference is where the order is stored

Common usage includes testing with in-memory repositories instead of databases, improving test speed while maintaining coverage.

Repository pattern applicability

Any infrastructure concern can be abstracted using repositories, not just storage operations.

At Alan, this pattern is rarely used since SQLAlchemy with PostgreSQL handles most cases, including tests.
However, the migration scenario provides clear justification:

  • Global business logic must be shared across countries
  • Storage approaches vary: global-only tables, global + country-specific tables, for 4 different countries

The business code remains consistent regardless of storage location.

Repository selection by application

All functions of the public API accept an app_name parameter.
The API choses which repository to use based on this app_name:

  • app_name=ALAN_BE: Write to global + Belgian tables
  • app_name=ALAN_FR: Write to global + French tables
  • app_name=None: Write to global tables only

What app_name should I use?

Write operations: Always pass app_name to maintain global/local table synchronization during migration. Read operations: Context-dependent usage:

- If you want to read exclusively from global tables, omit app_name (example: you want to apply some logics exclusively to members who have been migrated to the PaymentMethodService)
- If you want to read with a fallback to local tables, pass the app_name of the calling country (example: When charging with Stripe we don't care if the IBAN is coming from a local table or a global table)

Decoupling entities from models

Storing in different tables means being able to accommodate different table structures.
To do this properly, business code must remain independent of storage details.
The business logic should behave identically regardless of the target table.

In Domain-Driven Design, entities represent pure business concepts, isolated from infrastructure concerns.
Just as business code doesn't care where data is stored, it shouldn't care about the shape of storage objects.

Consider our earlier e-commerce example:

def place_order(items, order_repo):
    validate_items(items)
    send_success_notification(client)

    order = {"items": items, "total": sum(items)}
    order_repo.save(order)
    return order

The storage structure is irrelevant to this functionβ€”whether order is a single total column with separate item table, or a JSONB column.

The same principle applies to payment method entities.
BillingCustomer and other entities provide pure business representations containing all information needed for decisions and actions.
Storage format is an implementation detail.

# Local representation...
user = BeUser(profile_id=profile_id)
bundle = BeSignedBundle(signed_at="2025-01-01")
document = BeSignedDocument(signed_bundle_id=bundle.id, uri="s3:///abc")
iban = BeIBAN(iban="12345", signed_bundle_id=bundle.id, user_id=user.id)

# ...contains identical information to global representation
customer = BillingCustomer(profile_id=profile_id)
pm = SepaDirectDebitPaymentMethod(iban="12345", billing_customer_id=customer.id)
mandate = DebitMandate(uri="s3:///abc", signed_at="2025-01-01", payment_method_id=pm.id)

All we need are 'translation' functions (also called mappers) to freely convert from and to any of these different object shapes.

  • Entity β†’ global model
  • Entity β†’ local model
  • Local model β†’ entity
  • Global model β†’ entity

These mappers encapsulate some complexity and can be hard to read:

  • Differing relationship structures
  • Properties distributed across multiple objects
  • Data imbalance during migration (existing local data, limited global data)

But essentially, all mappers do is rearrange fields between representations.

The combination of repository pattern, entities, models and mappers fully decouples business logic from storage concerns.

Internal Structure

Only relevant if you plan on updating the component

Directory structure

  • internal/domain/ - Definition of entities and repository interfaces
  • internal/infrastructure/ - Concrete implementation of repositories
  • internal/models/ - SQLAlchemy models
  • internal/business_logic/ - Use-cases
  • internal/controllers/ - HTTP API controllers
  • public/ - External API interface, exposed to other components

Legal documents are stored in each country's component.
We rely on the app being mounted to resolve the template directory.

We use countries as a proxy for creditor

This approach hits limits because for certain use cases we use different legal entities to bill our customers, and therefore the SEPA mandates should be in the name of different entities.

The aggregate root pattern

BillingCustomer is the aggregate root of the bounded context. All queries and business operations target a customer directly.
We never fetch or write to an individual BillingPaymentMethod or DebitMandate.
The reason is that the status of entities within the aggregate root impact the status of the aggregate root itself

Example: If the only payment method of a customer becomes unchargeable because it is suspended on our PSPs, the customer as a whole is unchargeable.

In practical terms, this means:

  • All public methods accept profile_id or billing_customer_id. When we accept payment_method_id the first thing we do is fetching the customer who owns this payment method
  • Always load the complete customer with all payment methods and mandates in one query
  • Because we always join the entire graph at once, lazy loading is forbidden
  • To modify a payment method, retrieve the full customer, modify through the customer, then save the customer

If we were to add infinitely growing lists, such as a list of payments, lazy loading would be acceptable, even necessary

Tests

Prefer testing at the public API level to capture behavior that is most production like.
Avoid mocks unless when the concrete dependency is impossible to work with natively.
Avoid writing tests repeating too much boilerplate. Particularly, when testing different variants of the same function only one test should assert all the fields, test of variants should focus on the fields that actually change.

Deploying to another country

Checklist:

  • Implement repository for double write and fallback read logic
  • Update document generation logic with paths to the SEPA mandate templates
  • Update document generation logic to inject the right creditor information
  • Add routing of Dropbox Sign events to the PaymentMethodService in the callback handler handler of the country