Repository: cuenca-mx/cuenca-python

Issue:
add support for transfers using a card number
The API currently only supports a CLABE for transfers. We should also support card numbers (without having to specify the financial institution).

Here's the branch: https://github.com/cuenca-mx/cuenca-python/tree/payment_card

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/cuenca-mx/cuenca-python/pull/13?src=pr&el=h1) Report
> Merging [#13](https://codecov.io/gh/cuenca-mx/cuenca-python/pull/13?src=pr&el=desc) into [master](https://codecov.io/gh/cuenca-mx/cuenca-python/commit/fe958c099a4e1a121f022aa4b581511f601d95ad&el=desc) will **not change** coverage.
> The diff coverage is `100.00%`.


```diff
@@            Coverage Diff            @@
##            master       #13   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            9         9           
  Lines          238       244    +6     
=========================================
+ Hits           238       244    +6     
```

| Flag | Coverage Δ | |
|---|---|---|
| #unittests | `100.00% <100.00%> (ø)` | |

| [Impacted Files](https://codecov.io/gh/cuenca-mx/cuenca-python/pull/13?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [cuenca/resources/transfers.py](https://codecov.io/gh/cuenca-mx/cuenca-python/pull/13/diff?src=pr&el=tree#diff-Y3VlbmNhL3Jlc291cmNlcy90cmFuc2ZlcnMucHk=) | `100.00% <100.00%> (ø)` | |
| [cuenca/validators.py](https://codecov.io/gh/cuenca-mx/cuenca-python/pull/13/diff?src=pr&el=tree#diff-Y3VlbmNhL3ZhbGlkYXRvcnMucHk=) | `100.00% <100.00%> (ø)` | |
| [cuenca/version.py](https://codecov.io/gh/cuenca-mx/cuenca-python/pull/13/diff?src=pr&el=tree#diff-Y3VlbmNhL3ZlcnNpb24ucHk=) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/cuenca-mx/cuenca-python/pull/13?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/cuenca-mx/cuenca-python/pull/13?src=pr&el=footer). Last update [fe958c0...18bad51](https://codecov.io/gh/cuenca-mx/cuenca-python/pull/13?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/Makefile b/Makefile
index 5f3d646..d206683 100644
--- a/Makefile
+++ b/Makefile
@@ -2,8 +2,8 @@ SHELL := bash
 PATH := ./venv/bin:${PATH}
 PYTHON = python3.7
 PROJECT = cuenca
-isort = isort -rc -ac $(PROJECT) tests setup.py
-black = black -S -l 79 --target-version py38 $(PROJECT) tests setup.py
+isort = isort -rc -ac $(PROJECT) tests setup.py examples
+black = black -S -l 79 --target-version py38 $(PROJECT) tests setup.py examples
 
 
 all: test
diff --git a/README.md b/README.md
index 618da0c..7a9f676 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,23 @@
 [![codecov](https://codecov.io/gh/cuenca-mx/cuenca-python/branch/master/graph/badge.svg)](https://codecov.io/gh/cuenca-mx/cuenca-python)
 [![PyPI](https://img.shields.io/pypi/v/cuenca.svg)](https://pypi.org/project/cuenca/)
 
+# Installation
+
+`pip install cuenca`
+
+# Authentication
+
+The preferred way to configure the credentials for the client is to set the
+`CUENCA_API_KEY` and `CUENCA_API_SECRET` environment variables. The client
+library will automatically configure based on the values of those variables.
+
+To configure manually:
+```python
+import cuenca
+
+cuenca.configure(api_key='PKxxxx', api_secret='yyyyyy')
+```
+
 ## Transfers
 
 ### Create transfer
@@ -17,7 +34,7 @@ local_transfer_id = '078efdc20bab456285437309c4b75673'
 
 transfer = cuenca.Transfer.create(
     recipient_name='Benito Juárez',
-    account_number='646180157042875763',
+    account_number='646180157042875763',  # CLABE or card number
     amount=12345,  # Mx$123.45
     descriptor='sending money',  # As it'll appear for the customer
     idempotency_key=local_transfer_id
diff --git a/cuenca/resources/transfers.py b/cuenca/resources/transfers.py
index 87bbe30..ba2c5a9 100644
--- a/cuenca/resources/transfers.py
+++ b/cuenca/resources/transfers.py
@@ -1,18 +1,18 @@
 import datetime as dt
-from typing import ClassVar, Optional, cast
+from typing import ClassVar, List, Optional, Union, cast
 
 from clabe import Clabe
 from pydantic import BaseModel, StrictStr
 from pydantic.dataclasses import dataclass
 
 from ..types import Network, Status
-from ..validators import StrictPositiveInt, TransferQuery
+from ..validators import PaymentCardNumber, StrictPositiveInt, TransferQuery
 from .base import Creatable, Queryable, Retrievable
 
 
 class TransferRequest(BaseModel):
     recipient_name: StrictStr
-    account_number: Clabe
+    account_number: Union[Clabe, PaymentCardNumber]
     amount: StrictPositiveInt  # in centavos
     descriptor: StrictStr  # how it'll appear for the recipient
     idempotency_key: str  # must be unique for each transfer
@@ -72,6 +72,12 @@ class Transfer(Creatable, Queryable, Retrievable):
         )
         return cast('Transfer', cls._create(**req.dict()))
 
+    @classmethod
+    def create_many(cls, requests: List[TransferRequest]) -> List['Transfer']:
+        return [
+            cast('Transfer', cls._create(**req.dict())) for req in requests
+        ]
+
     @staticmethod
     def _gen_idempotency_key(account_number: str, amount: int) -> str:
         """
diff --git a/cuenca/validators.py b/cuenca/validators.py
index 782af4f..f888335 100644
--- a/cuenca/validators.py
+++ b/cuenca/validators.py
@@ -1,8 +1,11 @@
 import datetime as dt
-from typing import Optional
+from typing import ClassVar, Optional
 
 from pydantic import BaseModel, Extra, PositiveInt, StrictInt
-from pydantic.types import ConstrainedInt
+from pydantic.types import (
+    ConstrainedInt,
+    PaymentCardNumber as PydanticPaymentCardNumber,
+)
 
 from .types import sanitize_dict
 from .typing import DictStrAny
@@ -48,3 +51,8 @@ class TransferQuery(QueryParams):
 
 class ApiKeyQuery(QueryParams):
     active: Optional[bool] = None
+
+
+class PaymentCardNumber(PydanticPaymentCardNumber):
+    min_length: ClassVar[int] = 16
+    max_length: ClassVar[int] = 16
diff --git a/cuenca/version.py b/cuenca/version.py
index bf18f21..3e495be 100644
--- a/cuenca/version.py
+++ b/cuenca/version.py
@@ -1,3 +1,3 @@
-__version__ = '0.1.4'
+__version__ = '0.1.5'
 CLIENT_VERSION = __version__
 API_VERSION = '2020-03-19'
diff --git a/examples/batch_transfers.py b/examples/batch_transfers.py
new file mode 100644
index 0000000..1d6d0a9
--- /dev/null
+++ b/examples/batch_transfers.py
@@ -0,0 +1,31 @@
+import argparse
+import csv
+from dataclasses import fields
+
+from cuenca.resources.transfers import Transfer, TransferRequest
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description='Process batch transfers file'
+    )
+    parser.add_argument(
+        'input', type=str, help='Path to CSV batch transfers file',
+    )
+    parser.add_argument('output', type=str, help='Path to CSV output file')
+    args = parser.parse_args()
+    with open(args.input) as f:
+        reader = csv.DictReader(f)
+        # offline validation of transfers
+        transfer_requests = [TransferRequest(**line) for line in reader]
+    transfers = Transfer.create_many(transfer_requests)
+    with open(args.output, 'w') as f:
+        fieldnames = [field.name for field in fields(Transfer)]
+        writer = csv.DictWriter(f, fieldnames)
+        writer.writeheader()
+        for tr in transfers:
+            writer.writerow(tr.to_dict())
+
+
+if __name__ == '__main__':
+    main()


Test patch:
diff --git a/tests/resources/cassettes/test_transfers_create_many.yaml b/tests/resources/cassettes/test_transfers_create_many.yaml
new file mode 100644
index 0000000..5e63787
--- /dev/null
+++ b/tests/resources/cassettes/test_transfers_create_many.yaml
@@ -0,0 +1,108 @@
+interactions:
+- request:
+    body: '{"recipient_name": "Rogelio Lopez", "account_number": "646180157034181180",
+      "amount": 10000, "descriptor": "Mi primer transferencia", "idempotency_key":
+      "081e71c19f8640048090b7cd740205b1"}'
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Authorization:
+      - DUMMY
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '188'
+      Content-Type:
+      - application/json
+      User-Agent:
+      - cuenca-python/0.1.5.rc0
+      X-Cuenca-Api-Version:
+      - '2020-03-19'
+    method: POST
+    uri: https://sandbox.cuenca.com/transfers
+  response:
+    body:
+      string: '{"id":"TRK2s-yr17S_iAdawNSW0Rjw==","created_at":"2020-05-28T16:22:48.909272","updated_at":"2020-05-28T16:22:50.170473","account_number":"646180157034181180","recipient_name":"Rogelio
+        Lopez","amount":10000,"descriptor":"Mi primer transferencia","idempotency_key":"081e71c19f8640048090b7cd740205b1","status":"submitted","network":"internal","tracking_key":null}'
+    headers:
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '359'
+      Content-Type:
+      - application/json
+      Date:
+      - Thu, 28 May 2020 16:22:50 GMT
+      Via:
+      - 1.1 c90de501a57b4f0090a033690602dc10.cloudfront.net (CloudFront)
+      X-Amz-Cf-Id:
+      - SkktvJVvta9Xgv5LTOvIgxWFKRpjZ3wvwkF5Uwx995yaTdHJZ-R86A==
+      X-Amz-Cf-Pop:
+      - DFW55-C2
+      X-Amzn-Trace-Id:
+      - Root=1-5ecfe555-0835695eb1f7c70c0991840e;Sampled=0
+      X-Cache:
+      - Miss from cloudfront
+      x-amz-apigw-id:
+      - NQDFUFjdoAMFeUw=
+      x-amzn-RequestId:
+      - 4ca5296b-be89-4818-a675-49d66a45d667
+    status:
+      code: 201
+      message: Created
+- request:
+    body: '{"recipient_name": "Rogelio Lopez", "account_number": "646180157034181180",
+      "amount": 10001, "descriptor": "Mi primer transferencia", "idempotency_key":
+      "64850742e0c14bc1b93e4b762f072592"}'
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Authorization:
+      - DUMMY
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '188'
+      Content-Type:
+      - application/json
+      User-Agent:
+      - cuenca-python/0.1.5.rc0
+      X-Cuenca-Api-Version:
+      - '2020-03-19'
+    method: POST
+    uri: https://sandbox.cuenca.com/transfers
+  response:
+    body:
+      string: '{"id":"TRnaWQjdgQRs2CrrwQnTPfFg==","created_at":"2020-05-28T16:22:50.351720","updated_at":"2020-05-28T16:22:50.674677","account_number":"646180157034181180","recipient_name":"Rogelio
+        Lopez","amount":10001,"descriptor":"Mi primer transferencia","idempotency_key":"64850742e0c14bc1b93e4b762f072592","status":"submitted","network":"internal","tracking_key":null}'
+    headers:
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '359'
+      Content-Type:
+      - application/json
+      Date:
+      - Thu, 28 May 2020 16:22:50 GMT
+      Via:
+      - 1.1 c90de501a57b4f0090a033690602dc10.cloudfront.net (CloudFront)
+      X-Amz-Cf-Id:
+      - Zqd0hwjKpKizNqgSz_JjguVJlvQq-K0PTSH7dqu-2bnQxv8b-UWCYQ==
+      X-Amz-Cf-Pop:
+      - DFW55-C2
+      X-Amzn-Trace-Id:
+      - Root=1-5ecfe55a-2457598049a260809ab5b300;Sampled=0
+      X-Cache:
+      - Miss from cloudfront
+      x-amz-apigw-id:
+      - NQDGIGiQIAMFsxw=
+      x-amzn-RequestId:
+      - b32aceaa-c62f-446d-a984-09d997426ae1
+    status:
+      code: 201
+      message: Created
+version: 1
diff --git a/tests/resources/test_transfers.py b/tests/resources/test_transfers.py
index 6ed600c..9a9077f 100644
--- a/tests/resources/test_transfers.py
+++ b/tests/resources/test_transfers.py
@@ -3,6 +3,7 @@ from pydantic import ValidationError
 
 from cuenca import Transfer
 from cuenca.exc import MultipleResultsFound, NoResultFound
+from cuenca.resources.transfers import TransferRequest
 from cuenca.types import Network, Status
 
 
@@ -18,10 +19,37 @@ def test_transfers_create():
     assert transfer.idempotency_key is not None
     assert transfer.status
     assert transfer.status == Status.submitted
-    # Some seconds latter
+    assert transfer.network == Network.internal
+    # Some seconds later
     transfer.refresh()
     assert transfer.status == Status.succeeded
-    assert transfer.network == Network.internal
+
+
+@pytest.mark.vcr
+def test_transfers_create_many():
+    transfer_requests = [
+        TransferRequest(
+            account_number='646180157034181180',
+            amount=10000,
+            descriptor='Mi primer transferencia',
+            recipient_name='Rogelio Lopez',
+            idempotency_key='081e71c19f8640048090b7cd740205b1',
+        ),
+        TransferRequest(
+            account_number='646180157034181180',
+            amount=10001,
+            descriptor='Mi primer transferencia',
+            recipient_name='Rogelio Lopez',
+            idempotency_key='64850742e0c14bc1b93e4b762f072592',
+        ),
+    ]
+    transfers = Transfer.create_many(transfer_requests)
+    for transfer in transfers:
+        assert transfer.id is not None
+        assert transfer.idempotency_key is not None
+        assert transfer.status
+        assert transfer.status == Status.submitted
+        assert transfer.network == Network.internal
 
 
 @pytest.mark.vcr