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.
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:
- The creation process is owned and orchestrated by
PaymentMethodService - 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
IBANincomponents/fr - 23 occurrences of
BeIBANincomponents/be - 21 occurrences of
EsIBANincomponents/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
BillingCustomerModelfiltering byprofile_id(global model) - if we do not find anything in global tables, then we fallback to a read of
BeUserfiltering byprofile_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 tablesapp_name=ALAN_FR: Write to global + French tablesapp_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 interfacesinternal/infrastructure/- Concrete implementation of repositoriesinternal/models/- SQLAlchemy modelsinternal/business_logic/- Use-casesinternal/controllers/- HTTP API controllerspublic/- External API interface, exposed to other components
Legal documents¶
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_idorbilling_customer_id. When we acceptpayment_method_idthe 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
PaymentMethodServicein the callback handler handler of the country