-
-
Notifications
You must be signed in to change notification settings - Fork 292
Add base Subscribtion/recurring payments support #217
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
base: main
Are you sure you want to change the base?
Conversation
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #217 +/- ##
==========================================
- Coverage 78.42% 78.37% -0.05%
==========================================
Files 29 29
Lines 1979 2007 +28
Branches 244 244
==========================================
+ Hits 1552 1573 +21
- Misses 310 317 +7
Partials 117 117 ☔ View full report in Codecov by Sentry. |
payments/models.py
Outdated
| def get_user_email(self): | ||
| """ Get user email """ | ||
| try: | ||
| return self.get_user().email | ||
| except AttributeError: | ||
| return None | ||
|
|
||
| def get_user_first_name(self): | ||
| """ | ||
| Get user first name | ||
| Used only by PayU provider for now | ||
| """ | ||
| try: | ||
| return self.get_user().first_name | ||
| except AttributeError: | ||
| return None | ||
|
|
||
| def get_user_last_name(self): | ||
| """ | ||
| Get user last name | ||
| Used only by PayU provider for now | ||
| """ | ||
| try: | ||
| return self.get_user().last_name | ||
| except AttributeError: | ||
| return None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This ties the package to a particular shape of the user object, it would not work in at least some of our projects. Could those fields not be extracted from billing information?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These are the implementations for the most common case with intention, that users will override it in any other cases.
The billing information is info about the merchant, not the user, which is required by PayU (or PayU will ask that information in next step, which degrades UX)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But maybe it would be appropriate to throw NotImplementedError instead of returning None and write some note to the comments about thet.
a10fcfa to
2ef577a
Compare
|
Hi @patrys, I rebased this to current master, and the methods are throwing Can you please give a new review? I think, it would be handy to unify these methods among providers. |
WhyNotHugo
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've a few questions to further understand the needs behind this.
payments/models.py
Outdated
| def get_process_url(self) -> str: | ||
| return reverse('process_payment', kwargs={'token': self.token}) | ||
|
|
||
| def get_payment_url(self): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can raise RedirectNeeded in you provider code. Any application using this lib should catch these and redirect as needed.
Is there a reason that won't work for you?
payments/models.py
Outdated
| """ | ||
| raise NotImplementedError() | ||
|
|
||
| def get_user(self): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's this supposed to return?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is simple helper method used by the other get_user_* methods, so it is the only method, that needs to be overridden in case of the Django django.contrib.auth.models.User model.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're assuming that all applications have a User for each payment made.
I'm not a fan of imposing such a big restriction, especially without the need for one. You probably want a user in your own Payment instance, not in the abstract one in this package.
payments/models.py
Outdated
| """ Get the user asociated with this payment """ | ||
| raise NotImplementedError() | ||
|
|
||
| def get_user_email(self): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's wrong with billing_email? What's expected of this method?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The billing_* fields hold information about the issuer (Me), PayU (and potentially other providers) needs information about the user.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The billing_ fields all hold billing details, that is: the details of the person paying (this is what payment providers usually ask for, and the thing that actually varies on a per-payment basis).
This is the assumption made for other payment implementations, so it's the assumption that any third-party providers should make two.
Using billing_ fields as something elsemeans that applications cannot use PayU and another provider, since the give different semantic meaning to the same fields.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, this is something, I got totally wrong. Sorry for loss of your time and thank you for clearing that out.
I used this for storing issuer info when I was implementing PayPal (which doesn't use it at all), and then continued with the wrong implementation for PayU and recurring payments.
Maybe it would be useful to put some info about those fields into docs, I will try to write something.
payments/models.py
Outdated
| except AttributeError: | ||
| raise NotImplementedError() | ||
|
|
||
| def get_user_last_name(self): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ditto billing_last_name.
|
Here is sample implementation of the methods that connects |
6e63114 to
b6fad03
Compare
|
@WhyNotHugo @patrys Recently I was exploring PayPal subscription, which has completely different workflow (PayU payments are initiated from server, PayPal from the provider side). I have reworked this completely based on your comments and also I tried to make this compatible with the provider initiated subscription, so it could be used for providers like PayPal. This is still more proposal than done think, so I am open to suggestions how to make the whole recurring interface better. |
244c031 to
6bd1ef1
Compare
WhyNotHugo
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Apologies for the delay, each time I re-reviewed this I had different questions.
I think this looks good, I only have a few doubts on the current implementation...
payments/core.py
Outdated
| def autocomplete_with_subscription(self, payment): | ||
| """ | ||
| Complete the payment with subscription | ||
| Used by providers, that use server initiated subscription workflow | ||
| Throws RedirectNeeded if there is problem with the payment that needs to be solved by user | ||
| """ | ||
| raise NotImplementedError() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't fully understand what this function is supposed to do or return. I'm also thinking this as a "person implementing a Payment Provider subclass", and I wouldn't know what logic is expected here.
What do you mean by #274 has answered this question for me. Maybe clarify the docstring a bit so it's more obvious?server initiated here? The payment provider's server, or the current application?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1
It took me several hours to understand what the function is supposed to do. I guess that django-payments rather refers to this as to "making a payment". I would even not hesitate to call it pay_with_subscription() (ditto for the BasePayment class).
To clarify the docstring even more, I would say that this method is used by payments not providers and that it is used in an application-initiated workflow rather than in server-initiated workflow as it is not clear what server is meant (I believe I have seen somewhere that django-payments refers to the users of this library as to applications). (ditto for cancel_subscription and BasePayment class)
| token = models.CharField( | ||
| _("subscription token/id"), | ||
| help_text=_("Token/id used to identify subscription by provider"), | ||
| max_length=255, | ||
| default=None, | ||
| null=True, | ||
| blank=True, | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I noticed that (in previous versions) you needed additional fields to store the provider-specific data. Did you move those to your subclass of this model? What do you think of this being a JSONField so each provider can store all the data it may need?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think, that some kind of token or payment identification would be needed for most implementation.
I used this is for Subscription ID in case of PayPal and for Card token in case of PayU. Accessing that through JSONField would be quite unflexible.
But adding JSONField for additional fields might be a good idea.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But adding JSONField for additional fields might be a good idea.
Do you want to add those on this PR, or follow-up later?
payments/models.py
Outdated
| def get_subscription(self) -> Optional[BaseSubscription]: | ||
| """ | ||
| Returns subscription object associated with this payment | ||
| or None if the payment is not recurring | ||
| """ | ||
| return None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you think that using a foreign key to settings.PAYMENT_SUBSCRIPTION_MODEL makes sense, or would that be more trouble that what it's worth?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this enables more loose implementation.
I am using django-plans to which I added the recurring functionality with RecurringUserPlan model. Now I can do it without any ties to django-payments (it works even if the model is not based on BaseSubscription, it just has the same fields).
I am not sure, what would the foreign key would bring.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would go even further and change the autocomplete_with_subscription's signature to autocomplete_with_subscription(self, subscription). Then we would not need get_subscription (and is_recurring) at all and enable implementations to couple payments and subscriptions any way they want.
28c0167 to
f3d265a
Compare
| token = models.CharField( | ||
| _("subscription token/id"), | ||
| help_text=_("Token/id used to identify subscription by provider"), | ||
| max_length=255, | ||
| default=None, | ||
| null=True, | ||
| blank=True, | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But adding JSONField for additional fields might be a good idea.
Do you want to add those on this PR, or follow-up later?
payments/models.py
Outdated
| def set_recurrence(self, token: str, **kwargs): | ||
| """ | ||
| Sets token and other values associated with subscription recurrence | ||
| Kwargs can contain provider-specific values | ||
| """ | ||
| self.token = token |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess kwargs here would be assigned to that JSONField...?
payments/models.py
Outdated
| def get_subscription(self) -> Optional[BaseSubscription]: | ||
| """ | ||
| Returns subscription object associated with this payment | ||
| or None if the payment is not recurring | ||
| """ | ||
| return None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed.
WhyNotHugo
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense to me.
20bdcf2 to
c1e2f00
Compare
|
I have rebased this PR and fixed testing. I also added the requested @WhyNotHugo @patrys Could you please take a look? What needs to be done to make this merged? |
d95c757 to
249c784
Compare
|
I've tried to review this a few times, but I don't understand what this |
|
@WhyNotHugo The Wallet model represents type of recurring payments model used by PayU and other payment providers. |
34e7c45 to
d6beb42
Compare
f4df13a to
80f9828
Compare
Add core interface for server-initiated payments with stored payment methods. Components: - BaseWallet model with lifecycle (PENDING → ACTIVE → ERASED) - BasePayment methods: get_renew_token, set_renew_token, get_renew_data, autocomplete_with_wallet - BasicProvider methods: autocomplete_with_wallet, _finalize_wallet_payment, erase_wallet - WalletStatus constants (PENDING, ACTIVE, ERASED) This enables variable-amount recurring payments where the application controls when to charge and how much, unlike subscription-based systems where the provider manages both schedule and amount.
Implement autocomplete_with_wallet in DummyProvider as reference implementation showing: - Token retrieval and validation - Simulated server-initiated charge - Status updates and captured_amount - Wallet activation via _finalize_wallet_payment Serves as template for other provider implementations.
6b422d1 to
ad876c7
Compare
Add 14 mock-based tests verifying wallet interface following the established pattern from test_core.py. Tests cover: - WalletStatus constants and lifecycle - BaseWallet payment_completed, activate, erase - BasePayment token management (get/set_renew_token, get_renew_data) - DummyProvider autocomplete_with_wallet implementation - Provider helper methods (_finalize_wallet_payment) - Error handling (missing token)
Add comprehensive documentation (569 lines) covering: - Overview emphasizing variable amount capability - Architecture with flow diagrams - Amount flexibility section with examples - Step-by-step implementation guide - Multiple storage patterns - Provider implementation guide - Security best practices - Troubleshooting - Complete API reference Integrated into docs/index.rst and cross-referenced from docs/usage.rst.
Add working example showing: - Wallet model extending BaseWallet - Payment model with wallet FK - Token management implementation (get/set_renew_token) - Migration for wallet support Demonstrates the simple FK-based pattern for wallet integration.
Document v4.1.0 additions: - Wallet-based recurring payments interface - Components and use cases - Provider support status - Backward compatibility
- Add testmain to INSTALLED_APPS in test_settings.py - Update PYTHONPATH to testapp/testapp for testmain module discovery - Configure pytest testpaths to include testapp/testapp for integration tests - Fix PYTHONPATH order in tox.ini (root first to avoid symlink issues) - Add --ignore-glob for testapp/payments symlink This infrastructure supports both mock-based unit tests (in payments/) and integration tests with real DB (in testapp/testapp/testmain/).
Split wallet tests into mock-based unit tests and integration tests: Mock tests (payments/test_wallet.py - 17 tests): - Interface contracts and simple logic without database - Test wallet status choices, default values, token retrieval logic - Test provider delegation and error handling with mocks - Document usage patterns (FK pattern, custom storage) Integration tests (testapp/testapp/testmain/test_wallet.py - 12 tests): - Real database operations and model lifecycle - Wallet state transitions (activate, erase, payment_completed) - DummyProvider full workflow with real Payment/Wallet instances - End-to-end scenarios (first payment, recurring, variable amounts) DummyProvider enhancements (payments/dummy/__init__.py): - Add WalletStatus import - Add wallet status check before charging (security best practice) - Fix captured_amount and transaction_id persistence Total: 29 tests, all passing independently.
22815d7 to
d589fbc
Compare
for more information, see https://pre-commit.ci
|
I have significantly improved this PR in past two weeks. I added more documentation, updated the interface a bit etc. Now this interface is somehow confirmed to be viable by 3 providers: PayU, Stripe and Dummy. @WhyNotHugo What do you think now. Can we proceed with this or are there any more issues to be addressed? |
Changes of Payment model, that are required to implement PayU backend and recurring payments.
I could implement these changes through some mixin mandatory only for PayU backend, but I think, that those methods might be common to more backends and it would be highly usefull to have same implementation for all backends. I am willing to rewrite them bit more general shape, if requested.