From 20ea136e934f4c07d620c0e1dacd013712ff3c03 Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Fri, 17 Jul 2026 14:29:30 +0200 Subject: [PATCH] feat(BTI-22): add Credit Management solution Add Credit Management (CreditManagement3) as a solution, reached via app.solutions.create_solution("creditmanagement", ...). Covers 13 actions on the /json/DataRequest endpoint: invoice create/combined/credit-note, debtor add-update/info, debtor-file pause/resume, invoice pause/unpause/info, product lines, and payment plans. Wire format verified against the live Buckaroo test gateway (11/13 actions return 190/790 Success): invoice and currency are top-level request fields; description is top-level for CreatePaymentPlan; product-line articles pass as a method argument with Type/TotalAmount/TotalVat; combined invoices ride into a funding payment via combine(). base_builder: preserve group-type case in the scalar add_parameter branch (_upper_first) so multi-word group types like ProductLine survive on the wire; the list branch is deliberately left on .capitalize() to keep existing builders' requests byte-identical. Payment plans are spec-complete (all params gateway-accepted) but the happy path is unverified: the test account lacks an active Credit Management subscription and requires a past-due invoice. --- README.md | 161 ++++ buckaroo/builders/base_builder.py | 19 +- .../solutions/credit_management_builder.py | 560 ++++++++++++++ buckaroo/factories/solution_method_factory.py | 2 + examples/credit_management.py | 269 +++++++ .../solutions/test_credit_management.py | 496 ++++++++++++ .../test_concrete_solutions_contract.py | 1 + .../test_credit_management_builder.py | 732 ++++++++++++++++++ tests/unit/builders/test_base_builder.py | 43 + 9 files changed, 2282 insertions(+), 1 deletion(-) create mode 100644 buckaroo/builders/solutions/credit_management_builder.py create mode 100644 examples/credit_management.py create mode 100644 tests/feature/solutions/test_credit_management.py create mode 100644 tests/unit/builders/solutions/test_credit_management_builder.py diff --git a/README.md b/README.md index 992dd55..a980f61 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - [Instant Refunds](#instant-refunds) - [eMandate](#emandate) - [Split Payments](#split-payments) +- [Credit Management](#credit-management) - [Contribute](#contribute) - [Versioning](#versioning) - [Additional information](#additional-information) @@ -281,6 +282,166 @@ marketplaces.create_solution("marketplaces").manual_transfer({ A runnable demo of all six request types is in [`examples/marketplaces.py`](examples/marketplaces.py). +### Credit Management + +Credit Management is a DataRequest-based solution for invoicing and debtor +administration, reached through `app.solutions` rather than `app.payments`. +It covers creating and pausing invoices, managing debtors and their files, +credit notes, product lines, and payment plans. `create_combined_invoice` is +the exception — it builds a supplementary service that is *combined* into a +funding payment or refund, like Split Payments. + +`invoice` and `currency` are **top-level** request fields for `CreateInvoice`, +`CreateCombinedInvoice`, `CreateCreditNote`, `PauseInvoice`, `UnPauseInvoice` +and `InvoiceInfo` — set them via the top-level `invoice`/`currency` payload +keys (or `.invoice(...)`/`.currency(...)`), not inside `service_parameters`. +The gateway rejects them as service parameters with `ParameterMissing`. +`description` is likewise a **top-level** request field for +`CreatePaymentPlan` — set it via the top-level `description` payload key (or +`.description(...)`), not inside `service_parameters`. The gateway rejects it +as an unknown parameter when sent as one. +`schemeKey` is store-specific: it must belong to the same store as your store +key. `txnpk6` below is the scheme of the demo account used to write these +examples — replace it with the scheme key configured for your own store +(Plaza → Credit Management → CM scheme settings). + +```python +from buckaroo.app import Buckaroo + +app = Buckaroo.from_env() + +# Create an invoice (CreateInvoice — invoiceAmount, dueDate, schemeKey and a +# Debtor group with a code are required; invoice/currency go top-level) +response = app.solutions.create_solution( + "creditmanagement", + { + "invoice": "INV-001", + "currency": "EUR", + "service_parameters": { + "invoiceAmount": "250.00", + "dueDate": "2026-09-01", + "schemeKey": "txnpk6", + "debtor": {"code": "DEBTOR-001"}, + }, + }, +).create_invoice() +invoice_key = response.get_service_parameter("InvoiceKey") + +# Create or update a debtor (AddOrUpdateDebtor — a Debtor group with a code +# is required; Person/Company/Address/Email/Phone groups are optional) +response = app.solutions.create_solution( + "creditmanagement", + { + "service_parameters": { + "debtor": {"code": "DEBTOR-001"}, + "person": {"firstName": "John", "lastName": "Doe"}, + "address": {"street": "Main St", "city": "Amsterdam"}, + } + }, +).add_or_update_debtor() + +# Look up a debtor (DebtorInfo — a Debtor group with a code is required) +response = app.solutions.create_solution( + "creditmanagement", + {"service_parameters": {"debtor": {"code": "DEBTOR-001"}}}, +).debtor_info() + +# Add product lines (AddOrUpdateProductLines — articles must be passed as +# the `articles` method argument, not through service_parameters; each +# article requires type, totalAmount and totalVat on top of the usual +# identifier/description/quantity/price) +builder = app.solutions.create_solution( + "creditmanagement", + {"service_parameters": {"invoiceKey": "INVK-001"}}, +) +response = builder.add_or_update_product_lines( + articles=[ + { + "identifier": "SKU-1", + "description": "Widget", + "quantity": "2", + "price": "10.00", + "type": "Regular", + "totalAmount": "20.00", + "totalVat": "4.20", + "vatPercentage": "21", + }, + ] +) + +# Create a payment plan (CreatePaymentPlan — includedInvoiceKey, +# dossierNumber, startDate, interval, paymentPlanCostAmount and +# recipientEmail are required service parameters; description goes +# top-level; either installmentCount or installmentAmount must also be +# given. Requires an active Buckaroo Credit Management subscription and an +# included invoice past its due date — enforced by the gateway, not the SDK) +response = app.solutions.create_solution( + "creditmanagement", + { + "description": "3-month plan", + "service_parameters": { + "includedInvoiceKey": "INVK-001", + "dossierNumber": "DOSSIER-001", + "startDate": "2026-09-01", + "interval": "Month", + "paymentPlanCostAmount": "5.00", + "recipientEmail": "debtor@example.com", + "installmentCount": "3", + } + }, +).create_payment_plan() + +# Look up an invoice (InvoiceInfo — invoice is required, top-level) +response = app.solutions.create_solution( + "creditmanagement", {"invoice": "INV-001"} +).invoice_info() +``` + +`create_combined_invoice` accepts the same invoice service fields as +`create_invoice`, including the `Debtor` group, and combines into the +funding payment or refund. The combined request has a single shared top +level, so `invoice`/`currency` are set on the *funding* payment, not on the +CreditManagement3 sub-builder: + +```python +cm = app.solutions.create_solution( + "creditmanagement", + { + "service_parameters": { + "invoiceAmount": "95.00", + "dueDate": "2026-09-01", + "schemeKey": "txnpk6", + "debtor": {"code": "DEBTOR-001"}, + } + }, +).create_combined_invoice() + +response = ( + app.payments.create_payment("ideal", { + "currency": "EUR", + "amount": 95.00, + "invoice": "INV-002", + "description": "Combined invoice order INV-002", + "service_parameters": {"issuer": "ABNANL2A"}, + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", + }) + .combine(cm) + .pay() +) +``` + +Other actions follow the same shape: `create_credit_note` (`invoice` +top-level, `originalInvoiceNumber` and `Debtor` as service parameters), +`resume_debtor_file`/`pause_debtor_file`, `pause_invoice`/`unpause_invoice` +(`invoice` top-level, no service parameters), and `terminate_payment_plan` +(`includedInvoiceKey`). + +See [`examples/credit_management.py`](examples/credit_management.py) for a +runnable demo of the main actions. + ### Contribute We really appreciate it when developers contribute to improve the Buckaroo plugins. diff --git a/buckaroo/builders/base_builder.py b/buckaroo/builders/base_builder.py index 566728c..fc11a45 100644 --- a/buckaroo/builders/base_builder.py +++ b/buckaroo/builders/base_builder.py @@ -12,6 +12,18 @@ from ..services.service_parameter_validator import ServiceParameterValidator +def _upper_first(text: str) -> str: + """Upshift the first letter, leaving the rest of the case untouched. + + CreditManagement3's AddOrUpdateProductLines rejects a flattened + ``"Productline"`` group type — it needs ``"ProductLine"`` verbatim, so + ``str.capitalize()`` is unusable here: it lowercases everything after the + first letter. This still turns ``from_dict``'s lowercase keys + (``"debtor"``) into ``"Debtor"``. + """ + return text[:1].upper() + text[1:] + + class BaseBuilder(ABC): """Abstract base class for all builders (payments and solutions).""" @@ -153,6 +165,11 @@ def add_parameter( parameter = Parameter( name=item_key.capitalize(), value=str_value, + # Deliberately .capitalize() and not _upper_first(): existing + # builders reach this branch with camelCase keys (In3's + # "billingCustomer") and already ship "Billingcustomer" on the + # wire. Preserving the case here would change their requests, + # which is out of scope and unverified against the gateway. group_type=key.capitalize(), # e.g., "articles" group_id=str(index + 1), # 1-based index ) @@ -166,7 +183,7 @@ def add_parameter( parameter = Parameter( name=key.capitalize(), value=str_value, - group_type=group_type.capitalize(), + group_type=_upper_first(group_type), group_id=group_id, ) diff --git a/buckaroo/builders/solutions/credit_management_builder.py b/buckaroo/builders/solutions/credit_management_builder.py new file mode 100644 index 0000000..62619fa --- /dev/null +++ b/buckaroo/builders/solutions/credit_management_builder.py @@ -0,0 +1,560 @@ +from dataclasses import replace +from typing import Dict, Any, List, Optional +from .solution_builder import SolutionBuilder +from ...models.payment_request import CombinableService, PaymentRequest + + +class CreditManagementBuilder(SolutionBuilder): + """Builder for Credit Management solutions (DataRequest-based invoicing).""" + + # Grouped-parameter buckets for the debtor-detail actions. Keyed by the + # wire group type ("Debtor"/"Person"/...) — the validator matches grouped + # params by group type, not by individual field name. Populate these via + # ``add_parameter(name, value, group_type)`` or the ``service_parameters`` + # payload key, e.g. ``{"debtor": {"code": "..."}, "person": {...}}``. + _DEBTOR_DETAIL_GROUPS: Dict[str, Any] = { + "Person": { + "type": dict, + "required": False, + "description": "Debtor's individual details (name, gender, ...)", + }, + "Company": { + "type": dict, + "required": False, + "description": "Debtor's company details (name, VAT, chamber of commerce, ...)", + }, + "Address": { + "type": dict, + "required": False, + "description": "Debtor's address details", + }, + "Email": { + "type": dict, + "required": False, + "description": "Debtor's email address", + }, + "Phone": { + "type": dict, + "required": False, + "description": "Debtor's phone numbers", + }, + } + + # Debtor identification group, required by every action that identifies + # a debtor via its "Debtor" group (code, ...). + _DEBTOR_GROUP: Dict[str, Any] = { + "Debtor": { + "type": dict, + "required": True, + "description": "Debtor identification group (code, ...)", + }, + } + + # Scalar fields shared by CreateInvoice and CreateCombinedInvoice. + # CreateInvoice additionally accepts "poNumber" (see its branch below). + _INVOICE_FIELDS: Dict[str, Any] = { + "invoiceDate": { + "type": str, + "required": False, + "description": "Date the invoice was issued", + }, + "dueDate": { + "type": str, + "required": True, + "description": "Date the invoice payment is due", + }, + "invoiceAmount": { + "type": str, + "required": True, + "description": "Total invoice amount", + }, + "invoiceAmountVAT": { + "type": str, + "required": False, + "description": "VAT portion of the invoice amount", + }, + "schemeKey": { + "type": str, + "required": True, + "description": "Key of the credit management scheme to apply", + }, + "maxStepIndex": { + "type": str, + "required": False, + "description": "Maximum step index in the collection scheme", + }, + "allowedServices": { + "type": str, + "required": False, + "description": "CSV of payment services allowed to settle the invoice", + }, + "applyStartRecurrent": { + "type": str, + "required": False, + "description": "Whether to apply as the start of a recurrent scheme", + }, + } + + # Article field names (caller-friendly) that need remapping to their + # AddOrUpdateProductLines wire names. Unlisted article fields (quantity, + # type, totalAmount, totalVat, vatPercentage) pass through unchanged, + # arriving on the wire as ``Quantity``, ``Type``, ``Totalamount``, + # ``Totalvat`` and ``Vatpercentage`` (``.capitalize()``, not camelCase). + _ARTICLE_FIELD_MAP: Dict[str, str] = { + "identifier": "ProductId", + "description": "ProductName", + "price": "PricePerUnit", + } + + # Per-action, per-group field renames applied in ``build()`` just before + # the request is assembled. Keyed by lowercased action, then wire group + # type, then caller-friendly field name -> wire field name (matched + # case-insensitively against the already-added ``Parameter``s). + # DebtorInfo maps the debtor's code to wire name "Debtorcode" (not + # "Code", unlike AddOrUpdateDebtor/CreateInvoice); the wire name is run + # through ``.capitalize()`` like every other parameter name, so the + # camelCase written here does not survive as-is. + _WIRE_NAMES: Dict[str, Dict[str, Dict[str, str]]] = { + "debtorinfo": {"Debtor": {"code": "DebtorCode"}}, + } + + def get_service_name(self) -> str: + """Get the service name for Credit Management.""" + return "CreditManagement3" + + def get_allowed_service_parameters(self, action: str = "CreateInvoice") -> Dict[str, Any]: + """Get the allowed service parameters for Credit Management based on action.""" + + if action.lower() == "addorupdatedebtor": + return { + **self._DEBTOR_GROUP, + **self._DEBTOR_DETAIL_GROUPS, + } + + if action.lower() == "debtorinfo": + return {**self._DEBTOR_GROUP} + + if action.lower() in ("resumedebtorfile", "pausedebtorfile"): + return { + "debtorFileGuid": { + "type": str, + "required": True, + "description": "GUID of the debtor file", + }, + } + + if action.lower() in ["createinvoice"]: + return { + **self._INVOICE_FIELDS, + "poNumber": { + "type": str, + "required": False, + "description": "Purchase order number", + }, + **self._DEBTOR_GROUP, + **self._DEBTOR_DETAIL_GROUPS, + } + + if action.lower() in ("pauseinvoice", "unpauseinvoice", "invoiceinfo"): + # invoice is a top-level request field for these actions (set via + # .invoice(...) or the top-level "invoice" payload key), not a + # service parameter — the gateway rejects it as a service param + # with "Invoice: ParameterMissing". + return {} + + if action.lower() == "createcombinedinvoice": + return { + **self._INVOICE_FIELDS, + **self._DEBTOR_GROUP, + **self._DEBTOR_DETAIL_GROUPS, + } + + if action.lower() == "createcreditnote": + return { + "originalInvoiceNumber": { + "type": str, + "required": True, + "description": "Invoice number of the original invoice being credited", + }, + "invoiceDate": { + "type": str, + "required": True, + "description": "Date of the credit note", + }, + "invoiceAmount": { + "type": str, + "required": True, + "description": "Amount being credited", + }, + "invoiceAmountVAT": { + "type": str, + "required": False, + "description": "VAT amount being credited", + }, + **self._DEBTOR_GROUP, + } + + if action.lower() == "createpaymentplan": + return { + "includedInvoiceKey": { + "type": str, + "required": True, + "description": "Key of the invoice included in the payment plan", + }, + "dossierNumber": { + "type": str, + "required": True, + "description": "Dossier number for the payment plan", + }, + "startDate": { + "type": str, + "required": True, + "description": "Date the payment plan starts", + }, + "interval": { + "type": str, + "required": True, + "description": 'Interval between installments (e.g. "Month")', + }, + "paymentPlanCostAmount": { + "type": str, + "required": True, + "description": "Cost amount charged for the payment plan", + }, + "recipientEmail": { + "type": str, + "required": True, + "description": "Email address the payment plan is sent to", + }, + "installmentCount": { + "type": str, + "required": False, + "description": "Number of installments; either this or " + "installmentAmount must be specified", + }, + "installmentAmount": { + "type": str, + "required": False, + "description": "Amount per installment; either this or " + "installmentCount must be specified", + }, + } + + if action.lower() == "terminatepaymentplan": + return { + "includedInvoiceKey": { + "type": str, + "required": True, + "description": "Key of the invoice whose payment plan is terminated", + }, + } + + if action.lower() == "addorupdateproductlines": + return { + "invoiceKey": { + "type": str, + "required": True, + "description": "Key of the invoice to update", + }, + "ProductLine": { + "type": dict, + "required": True, + "description": "Product line (article) group(s): type, totalAmount, " + "totalVat, identifier, description, quantity, price, vatPercentage", + }, + } + + return {} + + def _with_wire_name(self, param, group_fields: Dict[str, Dict[str, str]]): + """Return ``param``, or a renamed copy of it if ``_WIRE_NAMES`` matches. + + Never mutates ``param`` itself — callers rely on the original + parameter surviving unchanged for other actions built from the same + builder (see ``build`` below). + """ + field_map = next( + ( + fields + for group_type, fields in group_fields.items() + if group_type.lower() == param.group_type.lower() + ), + None, + ) + if not field_map: + return param + wire_name = next( + ( + wire_name + for field_name, wire_name in field_map.items() + if field_name.lower() == param.name.lower() + ), + None, + ) + if not wire_name: + return param + return replace(param, name=wire_name.capitalize()) + + def build( + self, action: str = "Pay", validate: bool = True, strict_validation: bool = False + ) -> PaymentRequest: + """Build the request, applying ``_WIRE_NAMES`` renames for ``action`` first. + + Runs before every action method (and any direct ``build``/ + ``execute_action`` call), so the rename always applies regardless of + how the caller reaches this action. + + The rename is applied to a COPY of ``_service_parameters`` for the + duration of the build, then the original list is restored. Renaming + in place would leak across actions: a builder reused for + ``build("DebtorInfo")`` then ``build("AddOrUpdateDebtor")`` would keep + the "Debtorcode" rename DebtorInfo applies even though + AddOrUpdateDebtor needs "Code". + """ + group_fields = self._WIRE_NAMES.get(action.lower(), {}) + original = self._service_parameters + self._service_parameters = [self._with_wire_name(p, group_fields) for p in original] + try: + return super().build(action, validate, strict_validation) + finally: + self._service_parameters = original + + def create_invoice(self, validate: bool = True) -> Any: + """Create an invoice via CreateInvoice. + + ``invoice`` and ``currency`` are TOP-LEVEL request fields, not + service parameters — set them via ``.invoice(...)``/``.currency(...)`` + or the top-level ``invoice``/``currency`` payload keys (the gateway + rejects them as service parameters with ``ParameterMissing``). + Requires ``invoiceAmount``, ``dueDate`` and ``schemeKey`` to be set as + service parameters, plus a ``Debtor`` group with ``code`` set (via + ``add_parameter("code", "...", "Debtor")`` or the + ``service_parameters`` payload key, e.g. ``{"debtor": {"code": "..."}}``). + Optional ``Person``, ``Company``, ``Address``, ``Email`` and ``Phone`` + groups add debtor details the same way. Raises + :class:`RequiredParameterMissingError` when a required field is + missing and ``validate`` is True. + """ + payload = self.build("CreateInvoice", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def add_or_update_debtor(self, validate: bool = True) -> Any: + """Create or update a debtor via AddOrUpdateDebtor. + + Requires a ``Debtor`` group with ``code`` set (via + ``add_parameter("code", "...", "Debtor")`` or the + ``service_parameters`` payload key, e.g. ``{"debtor": {"code": "..."}}``). + Optional ``Person``, ``Company``, ``Address``, ``Email`` and ``Phone`` + groups add debtor details the same way. Raises + :class:`RequiredParameterMissingError` when the ``Debtor`` group is + missing and ``validate`` is True. + """ + payload = self.build("AddOrUpdateDebtor", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def debtor_info(self, validate: bool = True) -> Any: + """Retrieve debtor info via DebtorInfo. + + Requires a ``Debtor`` group with ``code`` set. Raises + :class:`RequiredParameterMissingError` when missing and ``validate`` + is True. + + Unlike ``AddOrUpdateDebtor``/``CreateInvoice``, DebtorInfo maps the + debtor's code to wire name ``Debtorcode`` rather than ``Code``; this + is rewritten automatically (see ``_WIRE_NAMES``) so callers keep + using ``code``. + """ + payload = self.build("DebtorInfo", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def resume_debtor_file(self, validate: bool = True) -> Any: + """Resume a paused debtor file via ResumeDebtorFile. + + Requires ``debtorFileGuid`` to be set. Raises + :class:`RequiredParameterMissingError` when missing and ``validate`` + is True. + """ + payload = self.build("ResumeDebtorFile", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def pause_debtor_file(self, validate: bool = True) -> Any: + """Pause a debtor file via PauseDebtorFile. + + Requires ``debtorFileGuid`` to be set. Raises + :class:`RequiredParameterMissingError` when missing and ``validate`` + is True. + """ + payload = self.build("PauseDebtorFile", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def pause_invoice(self, validate: bool = True) -> Any: + """Pause a single invoice via PauseInvoice. + + ``invoice`` is a TOP-LEVEL request field — set it via + ``.invoice(...)`` or the top-level ``invoice`` payload key, not as a + service parameter. + """ + payload = self.build("PauseInvoice", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def unpause_invoice(self, validate: bool = True) -> Any: + """Resume a paused invoice via UnPauseInvoice. + + ``invoice`` is a TOP-LEVEL request field — set it via + ``.invoice(...)`` or the top-level ``invoice`` payload key, not as a + service parameter. + """ + payload = self.build("UnPauseInvoice", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def invoice_info(self, validate: bool = True) -> Any: + """Retrieve invoice info via InvoiceInfo. + + ``invoice`` is a TOP-LEVEL request field — set it via + ``.invoice(...)`` or the top-level ``invoice`` payload key, not as a + service parameter. + """ + payload = self.build("InvoiceInfo", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def create_combined_invoice(self, validate: bool = True) -> CombinableService: + """Build a CreateCombinedInvoice service to combine into a payment or refund. + + Combine the result into the funding payment or refund, e.g. + ``payments.create_payment("ideal", {...}).combine(cm).pay()``, so the + invoice is created in the same request that settles it. + + Only this builder's ``Service`` entry rides along in the combined + request's ``ServiceList`` — top-level fields set on this builder + (``invoice``, ``currency``, ...) are discarded. ``invoice`` and + ``currency`` are still TOP-LEVEL request fields for CreateCombinedInvoice + (the gateway rejects them as service parameters), but since the + request has a single shared top level, set them on the *funding* + payment/refund builder instead (e.g. + ``payments.create_payment("ideal", {"invoice": "...", ...})``). + Accepts the same invoice service fields as ``create_invoice`` + (``invoiceAmount``, ``dueDate``, ``schemeKey``, ...) plus a ``Debtor`` + group (and optional ``Person``/``Company``/``Address``/``Email``/ + ``Phone`` groups) in place of a flat debtor ``code``, set via + ``add_parameter`` or the ``service_parameters`` payload key. + + Args: + validate: Whether to validate and filter service parameters. + """ + service = self.build("CreateCombinedInvoice", validate=validate).services.services[0] + # ``service.parameters`` is this builder's live ``_service_parameters`` + # list (passed by reference in ``BaseBuilder.build``). Copy it so a + # later ``add_parameter`` call on this builder can't mutate the + # "already built" combined service. + service = replace(service, parameters=list(service.parameters or [])) + return CombinableService(services=[service]) + + def create_credit_note(self, validate: bool = True) -> Any: + """Create a credit note via CreateCreditNote. + + ``invoice`` (the credit note's own number) is a TOP-LEVEL request + field — set it via ``.invoice(...)`` or the top-level ``invoice`` + payload key, not as a service parameter. Requires + ``originalInvoiceNumber`` (the invoice being credited) and a + ``Debtor`` group with ``code`` set as service parameters. Raises + :class:`RequiredParameterMissingError` when any of them are missing + and ``validate`` is True. + """ + payload = self.build("CreateCreditNote", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def create_payment_plan(self, validate: bool = True) -> Any: + """Create a payment plan via CreatePaymentPlan. + + Requires ``includedInvoiceKey``, ``dossierNumber``, ``startDate``, + ``interval``, ``paymentPlanCostAmount`` and ``recipientEmail`` to be + set (via ``add_parameter`` or the ``service_parameters`` payload key). + Also requires either ``installmentCount`` or ``installmentAmount`` + (the gateway rejects the request if neither is given, but the SDK does + not enforce this choice itself). Raises + :class:`RequiredParameterMissingError` when a required field is + missing and ``validate`` is True. + + ``description`` is a TOP-LEVEL request field, not a service parameter + — set it via ``.description(...)`` or the top-level ``description`` + payload key. The gateway rejects it as an unknown parameter when it is + sent inside the service's parameter list, and reports "a description is + required" when it is absent. + + The account must additionally have an active Buckaroo Credit + Management subscription, and the included invoice must be past its + due date — these are account/business rules enforced by the gateway, + not the SDK. + """ + payload = self.build("CreatePaymentPlan", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def terminate_payment_plan(self, validate: bool = True) -> Any: + """Terminate a payment plan via TerminatePaymentPlan. + + Requires ``includedInvoiceKey`` to be set. Raises + :class:`RequiredParameterMissingError` when missing and ``validate`` + is True. + """ + payload = self.build("TerminatePaymentPlan", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def add_or_update_product_lines( + self, articles: Optional[List[Dict[str, Any]]] = None, validate: bool = True + ) -> Any: + """Add or update an invoice's product lines via AddOrUpdateProductLines. + + Requires ``invoiceKey`` to be set (via ``add_parameter`` or the + ``service_parameters`` payload key) and at least one article in + ``articles``. The gateway requires each article to carry ``type`` + (``"Regular"`` for a normal line — ``"Product"`` is invalid), + ``totalAmount`` (or ``totalAmountExVat``) and ``totalVat``, on top of + the usual ``identifier``, ``description``, ``quantity`` and ``price``. + ``vatPercentage`` is also accepted. Each article is emitted as an + indexed ``ProductLine`` group, one group per article. ``identifier``, + ``description`` and ``price`` are renamed before hitting the wire + (``.capitalize()`` then flattens the casing), arriving as + ``Productid``, ``Productname`` and ``Priceperunit``; the rest + (``quantity``, ``type``, ``totalAmount``, ``totalVat``, + ``vatPercentage``) pass through unchanged, arriving on the wire as + ``Quantity``, ``Type``, ``Totalamount``, ``Totalvat`` and + ``Vatpercentage``. + + ``articles`` must be passed as this method's argument, not through + ``service_parameters`` — passing articles via ``service_parameters`` + builds a wrong ``Articles`` group and the gateway will reject it. + + Raises :class:`RequiredParameterMissingError` when ``invoiceKey`` or + ``articles`` is missing and ``validate`` is True. + """ + for index, article in enumerate(articles or [], start=1): + for name, value in article.items(): + wire_name = self._ARTICLE_FIELD_MAP.get(name, name) + self.add_parameter(wire_name, value, "ProductLine", str(index)) + + payload = self.build("AddOrUpdateProductLines", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) diff --git a/buckaroo/factories/solution_method_factory.py b/buckaroo/factories/solution_method_factory.py index 5cf81d2..4d6f7ba 100644 --- a/buckaroo/factories/solution_method_factory.py +++ b/buckaroo/factories/solution_method_factory.py @@ -5,6 +5,7 @@ from buckaroo.builders.solutions.subscription_builder import SubscriptionBuilder from buckaroo.builders.solutions.emandate_builder import EmandateB2BBuilder, EmandateBuilder from buckaroo.builders.solutions.marketplaces_builder import MarketplacesBuilder +from buckaroo.builders.solutions.credit_management_builder import CreditManagementBuilder from buckaroo.builders.solutions.default_builder import DefaultBuilder from buckaroo.builders.solutions.solution_builder import SolutionBuilder @@ -18,6 +19,7 @@ class SolutionMethodFactory(BuilderFactory): "emandate": EmandateBuilder, "emandateb2b": EmandateB2BBuilder, "marketplaces": MarketplacesBuilder, + "creditmanagement": CreditManagementBuilder, } @classmethod diff --git a/examples/credit_management.py b/examples/credit_management.py new file mode 100644 index 0000000..ab7f1d8 --- /dev/null +++ b/examples/credit_management.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Demo of the Credit Management solution (``creditmanagement``, service ``CreditManagement3``). + +Credit Management is a DataRequest-based solution for invoicing and debtor +administration: create invoices, manage debtors, run payment plans, and pause +or look up invoices and debtor files. ``create_combined_invoice`` is the +exception — it builds a supplementary service that is *combined* into a +funding payment or refund, like Split Payments. Gated on +``BUCKAROO_STORE_KEY`` / ``BUCKAROO_SECRET_KEY`` env vars. +""" + +import os +import sys + +# Add parent directory to Python path so the demo can import the SDK in-place. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from buckaroo.app import Buckaroo + + +def _have_credentials() -> bool: + if not os.getenv("BUCKAROO_STORE_KEY") or not os.getenv("BUCKAROO_SECRET_KEY"): + print("⚠️ Set BUCKAROO_STORE_KEY and BUCKAROO_SECRET_KEY to run this demo") + return False + return True + + +def demo_create_invoice() -> None: + """Create an invoice via CreateInvoice. + + ``invoice`` and ``currency`` are TOP-LEVEL request fields, not service + parameters — the gateway rejects them as service params with + ``ParameterMissing``. ``schemeKey`` must belong to the same store as your + store key; ``txnpk6`` below is the demo account's scheme — replace it with + your own from Plaza. ``debtor.code`` is required. + """ + print("\n1. Create invoice") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + response = app.solutions.create_solution( + "creditmanagement", + { + "invoice": "INV-DEMO-001", + "currency": "EUR", + "service_parameters": { + "invoiceAmount": "250.00", + "dueDate": "2026-09-01", + "schemeKey": "txnpk6", + "debtor": {"code": "DEBTOR-DEMO-001"}, + }, + }, + ).create_invoice() + invoice_key = response.get_service_parameter("InvoiceKey") + print(f" status.code={response.status.code.code} invoiceKey={invoice_key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_create_combined_invoice() -> None: + """CreateCombinedInvoice: build the invoice, combine it into an iDEAL payment. + + The combined request has a single shared top level, so ``invoice`` and + ``currency`` are set on the *funding* iDEAL payment below, not on the + CreditManagement3 sub-builder. + """ + print("\n2. Create combined invoice") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + cm = app.solutions.create_solution( + "creditmanagement", + { + "service_parameters": { + "invoiceAmount": "95.00", + "dueDate": "2026-09-01", + "schemeKey": "txnpk6", + "debtor": {"code": "DEBTOR-DEMO-001"}, + } + }, + ).create_combined_invoice() + + response = ( + app.payments.create_payment( + "ideal", + { + "currency": "EUR", + "amount": 95.00, + "invoice": "INV-DEMO-002", + "description": "Combined invoice order INV-DEMO-002", + "service_parameters": {"issuer": "ABNANL2A"}, + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", + }, + ) + .combine(cm) + .pay() + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_add_or_update_debtor() -> None: + """Create or update a debtor via AddOrUpdateDebtor with grouped debtor details.""" + print("\n3. Add or update debtor") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + response = app.solutions.create_solution( + "creditmanagement", + { + "service_parameters": { + "debtor": {"code": "DEBTOR-DEMO-001"}, + "person": {"firstName": "John", "lastName": "Doe"}, + "address": {"street": "Main St", "city": "Amsterdam"}, + "email": {"email": "john@example.com"}, + } + }, + ).add_or_update_debtor() + debtor_key = response.get_service_parameter("DebtorKey") + print(f" status.code={response.status.code.code} debtorKey={debtor_key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_debtor_info() -> None: + """Look up a debtor via DebtorInfo. ``debtor.code`` is required.""" + print("\n4. Debtor info") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + response = app.solutions.create_solution( + "creditmanagement", + {"service_parameters": {"debtor": {"code": "DEBTOR-DEMO-001"}}}, + ).debtor_info() + status = response.get_service_parameter("Status") + print(f" status.code={response.status.code.code} debtorStatus={status}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_add_or_update_product_lines() -> None: + """Add product lines via AddOrUpdateProductLines. + + Articles must be passed as the ``articles`` method argument, not through + ``service_parameters`` — the latter builds a wrong ``Articles`` group. + Each article requires ``type`` (``"Regular"`` for a normal line), + ``totalAmount`` and ``totalVat`` on top of the usual fields. + """ + print("\n5. Add or update product lines") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + builder = app.solutions.create_solution( + "creditmanagement", + {"service_parameters": {"invoiceKey": "INVK-DEMO-001"}}, + ) + response = builder.add_or_update_product_lines( + articles=[ + { + "identifier": "SKU-1", + "description": "Widget", + "quantity": "2", + "price": "10.00", + "type": "Regular", + "totalAmount": "20.00", + "totalVat": "4.20", + "vatPercentage": "21", + }, + ] + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_create_payment_plan() -> None: + """Create a payment plan via CreatePaymentPlan. + + ``description`` is a TOP-LEVEL request field, not a service parameter — + the gateway rejects it as an unknown parameter when sent as one. + + Requires an active Buckaroo Credit Management subscription on the + account and an included invoice that is past its due date — the gateway + enforces these, not the SDK. + """ + print("\n6. Create payment plan") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + response = app.solutions.create_solution( + "creditmanagement", + { + "description": "3-month plan", + "service_parameters": { + "includedInvoiceKey": "INVK-DEMO-001", + "dossierNumber": "DOSSIER-DEMO-001", + "startDate": "2026-09-01", + "interval": "Month", + "paymentPlanCostAmount": "5.00", + "recipientEmail": "debtor@example.com", + "installmentCount": "3", + }, + }, + ).create_payment_plan() + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_invoice_info() -> None: + """Look up an invoice via InvoiceInfo. + + ``invoice`` is a TOP-LEVEL request field, not a service parameter. + """ + print("\n7. Invoice info") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + response = app.solutions.create_solution( + "creditmanagement", {"invoice": "INV-DEMO-001"} + ).invoice_info() + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def main() -> None: + print("BUCKAROO SDK — CREDIT MANAGEMENT DEMO") + print("=" * 60) + + demo_create_invoice() + demo_create_combined_invoice() + demo_add_or_update_debtor() + demo_debtor_info() + demo_add_or_update_product_lines() + demo_create_payment_plan() + demo_invoice_info() + + print("\n" + "=" * 60) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/tests/feature/solutions/test_credit_management.py b/tests/feature/solutions/test_credit_management.py new file mode 100644 index 0000000..10dfe45 --- /dev/null +++ b/tests/feature/solutions/test_credit_management.py @@ -0,0 +1,496 @@ +import pytest + +from buckaroo.exceptions._parameter_validation_error import ( + ParameterValidationError, + RequiredParameterMissingError, +) +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers +from tests.support.recording_mock import ( + recorded_action, + recorded_request, + recorded_service_parameters, +) + + +class TestCreditManagementFeature: + """Feature tests for the Credit Management solution.""" + + def test_credit_management_is_available(self, buckaroo): + assert buckaroo.solutions.is_method_supported("creditmanagement") + + def test_create_invoice_posts_service_parameters_and_returns_invoice_key( + self, buckaroo, mock_strategy + ): + response_body = Helpers.success_response( + { + "Services": [ + { + "Name": "CreditManagement3", + "Action": "CreateInvoice", + "Parameters": [{"Name": "InvoiceKey", "Value": "INVK-999"}], + } + ], + "ServiceCode": "CreditManagement3", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution( + "creditmanagement", + { + "invoice": "INV-999", + "currency": "EUR", + "service_parameters": { + "invoiceAmount": "250.00", + "dueDate": "2026-09-01", + "schemeKey": "SCHEME-2", + "debtor": {"code": "DEBTOR-999"}, + }, + }, + ).create_invoice() + + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + assert response.get_service_parameter("InvoiceKey") == "INVK-999" + assert recorded_action(mock_strategy) == "CreateInvoice" + + # invoice/currency are top-level request fields, not service + # parameters — the gateway rejects them as service params. + request = recorded_request(mock_strategy) + assert request["Invoice"] == "INV-999" + assert request["Currency"] == "EUR" + + params = { + (p["Name"], p["GroupType"]): p["Value"] + for p in recorded_service_parameters(mock_strategy) + } + assert ("Invoice", "") not in params + assert ("Currency", "") not in params + assert params[("Invoiceamount", "")] == "250.00" + assert params[("Duedate", "")] == "2026-09-01" + assert params[("Schemekey", "")] == "SCHEME-2" + assert params[("Code", "Debtor")] == "DEBTOR-999" + + def test_create_invoice_without_required_params_raises_before_wire(self, buckaroo): + builder = buckaroo.solutions.create_solution("creditmanagement") + + with pytest.raises(ParameterValidationError): + builder.create_invoice() + + def test_create_invoice_without_debtor_code_raises_before_wire(self, buckaroo): + builder = buckaroo.solutions.create_solution( + "creditmanagement", + { + "invoice": "INV-1", + "service_parameters": { + "invoiceAmount": "10.00", + "dueDate": "2026-09-01", + "schemeKey": "SCHEME-1", + }, + }, + ) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.create_invoice() + + assert exc.value.parameter_name == "Debtor" + + def test_add_or_update_debtor_posts_grouped_debtor_details(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [ + { + "Name": "CreditManagement3", + "Action": "AddOrUpdateDebtor", + "Parameters": [{"Name": "DebtorKey", "Value": "DEBK-999"}], + } + ], + "ServiceCode": "CreditManagement3", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution( + "creditmanagement", + { + "service_parameters": { + "debtor": {"code": "DEBTOR-999"}, + "person": {"firstName": "John", "lastName": "Doe"}, + "address": {"street": "Main St", "city": "Amsterdam"}, + "email": {"email": "john@example.com"}, + } + }, + ).add_or_update_debtor() + + assert response.status.code.code == 190 + assert response.get_service_parameter("DebtorKey") == "DEBK-999" + assert recorded_action(mock_strategy) == "AddOrUpdateDebtor" + + params = { + (p["Name"], p["GroupType"]): p["Value"] + for p in recorded_service_parameters(mock_strategy) + } + assert params[("Code", "Debtor")] == "DEBTOR-999" + assert params[("Firstname", "Person")] == "John" + assert params[("Lastname", "Person")] == "Doe" + assert params[("Street", "Address")] == "Main St" + assert params[("City", "Address")] == "Amsterdam" + assert params[("Email", "Email")] == "john@example.com" + + def test_add_or_update_debtor_without_debtor_group_raises_before_wire(self, buckaroo): + builder = buckaroo.solutions.create_solution( + "creditmanagement", + {"service_parameters": {"person": {"firstName": "John"}}}, + ) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.add_or_update_debtor() + + assert exc.value.parameter_name == "Debtor" + + def test_debtor_info_posts_debtor_code_and_returns_details(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [ + { + "Name": "CreditManagement3", + "Action": "DebtorInfo", + "Parameters": [{"Name": "Status", "Value": "Active"}], + } + ], + "ServiceCode": "CreditManagement3", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution( + "creditmanagement", + {"service_parameters": {"debtor": {"code": "DEBTOR-999"}}}, + ).debtor_info() + + assert response.get_service_parameter("Status") == "Active" + assert recorded_action(mock_strategy) == "DebtorInfo" + params = { + (p["Name"], p["GroupType"]): p["Value"] + for p in recorded_service_parameters(mock_strategy) + } + assert params[("Debtorcode", "Debtor")] == "DEBTOR-999" + + @pytest.mark.parametrize( + "method_name,action", + [ + ("resume_debtor_file", "ResumeDebtorFile"), + ("pause_debtor_file", "PauseDebtorFile"), + ], + ) + def test_debtor_file_actions_post_debtor_file_guid( + self, buckaroo, mock_strategy, method_name, action + ): + response_body = Helpers.success_response( + {"Services": [], "ServiceCode": "CreditManagement3"} + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + builder = buckaroo.solutions.create_solution( + "creditmanagement", + {"service_parameters": {"debtorFileGuid": "FILE-GUID-999"}}, + ) + response = getattr(builder, method_name)() + + assert response.status.code.code == 190 + assert recorded_action(mock_strategy) == action + params = {p["Name"]: p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert params["Debtorfileguid"] == "FILE-GUID-999" + + @pytest.mark.parametrize("method_name", ["resume_debtor_file", "pause_debtor_file"]) + def test_debtor_file_actions_without_debtor_file_guid_raise_before_wire( + self, buckaroo, method_name + ): + builder = buckaroo.solutions.create_solution("creditmanagement") + + with pytest.raises(RequiredParameterMissingError) as exc: + getattr(builder, method_name)() + + assert exc.value.parameter_name == "debtorFileGuid" + + @pytest.mark.parametrize( + "method_name,action", + [ + ("pause_invoice", "PauseInvoice"), + ("unpause_invoice", "UnPauseInvoice"), + ("invoice_info", "InvoiceInfo"), + ], + ) + def test_invoice_actions_post_invoice(self, buckaroo, mock_strategy, method_name, action): + response_body = Helpers.success_response( + {"Services": [], "ServiceCode": "CreditManagement3"} + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + # invoice is a top-level request field for these actions, not a + # service parameter. + builder = buckaroo.solutions.create_solution( + "creditmanagement", + {"invoice": "INV-999"}, + ) + response = getattr(builder, method_name)() + + assert response.status.code.code == 190 + assert recorded_action(mock_strategy) == action + request = recorded_request(mock_strategy) + assert request["Invoice"] == "INV-999" + + def test_create_credit_note_posts_original_invoice_and_debtor(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + {"Services": [], "ServiceCode": "CreditManagement3"} + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution( + "creditmanagement", + { + "invoice": "CN-001", + "service_parameters": { + "originalInvoiceNumber": "INV-999", + "invoiceDate": "2026-07-16", + "invoiceAmount": 10.00, + "debtor": {"code": "DEBTOR-999"}, + }, + }, + ).create_credit_note() + + assert response.status.code.code == 190 + assert recorded_action(mock_strategy) == "CreateCreditNote" + + request = recorded_request(mock_strategy) + assert request["Invoice"] == "CN-001" + + params = { + (p["Name"], p["GroupType"]): p["Value"] + for p in recorded_service_parameters(mock_strategy) + } + assert ("Invoice", "") not in params + assert params[("Originalinvoicenumber", "")] == "INV-999" + assert params[("Code", "Debtor")] == "DEBTOR-999" + + def test_create_credit_note_without_debtor_raises_before_wire(self, buckaroo): + builder = buckaroo.solutions.create_solution( + "creditmanagement", + { + "invoice": "CN-001", + "service_parameters": { + "originalInvoiceNumber": "INV-999", + "invoiceDate": "2026-07-16", + "invoiceAmount": 10.00, + }, + }, + ) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.create_credit_note() + + assert exc.value.parameter_name == "Debtor" + + def test_add_or_update_product_lines_posts_indexed_articles(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + {"Services": [], "ServiceCode": "CreditManagement3"} + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + builder = buckaroo.solutions.create_solution( + "creditmanagement", + {"service_parameters": {"invoiceKey": "INVK-999"}}, + ) + response = builder.add_or_update_product_lines( + articles=[ + {"identifier": "SKU-1", "description": "Widget", "quantity": "2", "price": "10.00"}, + ] + ) + + assert response.status.code.code == 190 + assert recorded_action(mock_strategy) == "AddOrUpdateProductLines" + params = { + (p["Name"], p["GroupType"], p["GroupID"]): p["Value"] + for p in recorded_service_parameters(mock_strategy) + } + assert params[("Invoicekey", "", "")] == "INVK-999" + assert params[("Productid", "ProductLine", "1")] == "SKU-1" + assert params[("Productname", "ProductLine", "1")] == "Widget" + assert params[("Quantity", "ProductLine", "1")] == "2" + assert params[("Priceperunit", "ProductLine", "1")] == "10.00" + + def test_add_or_update_product_lines_without_articles_raises_before_wire(self, buckaroo): + builder = buckaroo.solutions.create_solution( + "creditmanagement", + {"service_parameters": {"invoiceKey": "INVK-999"}}, + ) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.add_or_update_product_lines() + + assert exc.value.parameter_name == "ProductLine" + + def test_create_payment_plan_posts_installment_and_dossier_fields( + self, buckaroo, mock_strategy + ): + response_body = Helpers.success_response( + {"Services": [], "ServiceCode": "CreditManagement3"} + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution( + "creditmanagement", + { + # description is a top-level request field for + # CreatePaymentPlan, not a service parameter — the gateway + # rejects it as an unknown parameter when sent as one. + "description": "3-month plan", + "service_parameters": { + "includedInvoiceKey": "INVK-999", + "dossierNumber": "DOSSIER-999", + "startDate": "2026-09-01", + "interval": "Month", + "paymentPlanCostAmount": "5.00", + "recipientEmail": "debtor@example.com", + "installmentCount": "3", + }, + }, + ).create_payment_plan() + + assert response.status.code.code == 190 + assert recorded_action(mock_strategy) == "CreatePaymentPlan" + + request = recorded_request(mock_strategy) + assert request["Description"] == "3-month plan" + + params = {p["Name"]: p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert params["Includedinvoicekey"] == "INVK-999" + assert params["Dossiernumber"] == "DOSSIER-999" + assert params["Startdate"] == "2026-09-01" + assert params["Interval"] == "Month" + assert params["Paymentplancostamount"] == "5.00" + assert params["Recipientemail"] == "debtor@example.com" + assert params["Installmentcount"] == "3" + assert "Description" not in params + + def test_create_payment_plan_without_included_invoice_key_raises_before_wire(self, buckaroo): + builder = buckaroo.solutions.create_solution( + "creditmanagement", + { + "description": "3-month plan", + "service_parameters": { + "dossierNumber": "DOSSIER-999", + "startDate": "2026-09-01", + "interval": "Month", + "paymentPlanCostAmount": "5.00", + "recipientEmail": "debtor@example.com", + }, + }, + ) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.create_payment_plan() + + assert exc.value.parameter_name == "includedInvoiceKey" + + def test_terminate_payment_plan_posts_included_invoice_key(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + {"Services": [], "ServiceCode": "CreditManagement3"} + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution( + "creditmanagement", + {"service_parameters": {"includedInvoiceKey": "INVK-999"}}, + ).terminate_payment_plan() + + assert response.status.code.code == 190 + assert recorded_action(mock_strategy) == "TerminatePaymentPlan" + params = {p["Name"]: p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert params["Includedinvoicekey"] == "INVK-999" + + def test_terminate_payment_plan_without_included_invoice_key_raises_before_wire(self, buckaroo): + builder = buckaroo.solutions.create_solution("creditmanagement") + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.terminate_payment_plan() + + assert exc.value.parameter_name == "includedInvoiceKey" + + +class TestCreditManagementCombinedInvoice: + """CreateCombinedInvoice rides on a payment via combine(), like Marketplaces.""" + + def test_create_combined_invoice_combines_into_ideal_pay(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [ + { + "Name": "CreditManagement3", + "Action": None, + "Parameters": [{"Name": "InvoiceKey", "Value": "INVK-COMBINED-1"}], + }, + {"Name": "ideal", "Action": None, "Parameters": []}, + ], + "ServiceCode": "ideal", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + + # invoice/currency are top-level request fields, and the combined + # request has a single shared top level — so they're set on the + # funding payment (below), not on the CreditManagement3 sub-builder. + cm = buckaroo.solutions.create_solution( + "creditmanagement", + { + "service_parameters": { + "invoiceAmount": "95.00", + "dueDate": "2026-09-01", + "schemeKey": "SCHEME-1", + "debtor": {"code": "DEBTOR-999"}, + } + }, + ).create_combined_invoice() + + payload = Helpers.standard_payload( + invoice="INV-COMBINED-1", + amount=95.00, + service_parameters={"issuer": "ABNANL2A"}, + ) + response = buckaroo.payments.create_payment("ideal", payload).combine(cm).pay() + + body = recorded_request(mock_strategy) + assert body["Invoice"] == "INV-COMBINED-1" + + services = body["Services"]["ServiceList"] + # Payment method first, combined CreditManagement3 service second. + assert [s["Name"] for s in services] == ["ideal", "CreditManagement3"] + assert services[0]["Action"] == "Pay" + assert services[1]["Action"] == "CreateCombinedInvoice" + + invoice_params = { + (p["Name"], p["GroupType"]): p["Value"] for p in services[1]["Parameters"] + } + assert ("Invoice", "") not in invoice_params + assert invoice_params[("Invoiceamount", "")] == "95.00" + assert invoice_params[("Code", "Debtor")] == "DEBTOR-999" + + assert response.get_service_parameter("InvoiceKey") == "INVK-COMBINED-1" + + def test_create_combined_invoice_without_debtor_raises_before_wire(self, buckaroo): + builder = buckaroo.solutions.create_solution( + "creditmanagement", + { + "service_parameters": { + "invoiceAmount": "10.00", + "dueDate": "2026-09-01", + "schemeKey": "SCHEME-1", + } + }, + ) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.create_combined_invoice() + + assert exc.value.parameter_name == "Debtor" diff --git a/tests/unit/builders/solutions/test_concrete_solutions_contract.py b/tests/unit/builders/solutions/test_concrete_solutions_contract.py index 40a8341..4343cc2 100644 --- a/tests/unit/builders/solutions/test_concrete_solutions_contract.py +++ b/tests/unit/builders/solutions/test_concrete_solutions_contract.py @@ -31,6 +31,7 @@ "emandate": "GetIssuerList", "emandateb2b": "GetIssuerList", "marketplaces": "Split", + "creditmanagement": "CreateInvoice", } diff --git a/tests/unit/builders/solutions/test_credit_management_builder.py b/tests/unit/builders/solutions/test_credit_management_builder.py new file mode 100644 index 0000000..a4d0574 --- /dev/null +++ b/tests/unit/builders/solutions/test_credit_management_builder.py @@ -0,0 +1,732 @@ +"""Unit tests for :class:`CreditManagementBuilder`.""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.solutions.credit_management_builder import CreditManagementBuilder +from buckaroo.builders.solutions.solution_builder import SolutionBuilder +from buckaroo.exceptions._parameter_validation_error import RequiredParameterMissingError +from buckaroo.models.payment_request import CombinableService +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action, recorded_request + + +def test_construction_with_client_succeeds(client): + builder = CreditManagementBuilder(client) + assert isinstance(builder, CreditManagementBuilder) + assert isinstance(builder, SolutionBuilder) + + +def test_get_service_name_returns_credit_management3(client): + assert CreditManagementBuilder(client).get_service_name() == "CreditManagement3" + + +def test_get_allowed_service_parameters_create_invoice_marks_required_fields(client): + builder = CreditManagementBuilder(client) + params = builder.get_allowed_service_parameters("CreateInvoice") + + assert set(params.keys()) == { + "invoiceDate", + "dueDate", + "invoiceAmount", + "invoiceAmountVAT", + "schemeKey", + "maxStepIndex", + "allowedServices", + "applyStartRecurrent", + "poNumber", + "Debtor", + "Person", + "Company", + "Address", + "Email", + "Phone", + } + + required = {"invoiceAmount", "dueDate", "schemeKey", "Debtor"} + for name, config in params.items(): + assert config["required"] is (name in required), f"{name} required flag mismatch" + + +def test_get_allowed_service_parameters_create_invoice_is_case_insensitive(client): + builder = CreditManagementBuilder(client) + assert builder.get_allowed_service_parameters("createinvoice") == ( + builder.get_allowed_service_parameters("CreateInvoice") + ) + + +def test_create_invoice_posts_to_data_request_and_parses_response(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + { + "Key": "creditmanagement-key-123", + "Status": {"Code": {"Code": 190}}, + "Services": [ + { + "Name": "CreditManagement3", + "Action": "CreateInvoice", + "Parameters": [ + {"Name": "InvoiceKey", "Value": "INVK-001"}, + ], + } + ], + }, + ) + ) + + builder = CreditManagementBuilder(client) + builder.invoice("INV-001") + builder.currency("EUR") + builder.add_parameter("invoiceAmount", "100.00") + builder.add_parameter("dueDate", "2026-08-01") + builder.add_parameter("schemeKey", "SCHEME-1") + builder.add_parameter("code", "DEBTOR-001", "Debtor") + response = builder.create_invoice() + + assert response.key == "creditmanagement-key-123" + assert response.get_service_parameter("InvoiceKey") == "INVK-001" + assert recorded_action(mock_strategy) == "CreateInvoice" + + +def test_create_invoice_raises_when_debtor_code_missing(client): + builder = CreditManagementBuilder(client) + builder.invoice("INV-001") + builder.add_parameter("invoiceAmount", "100.00") + builder.add_parameter("dueDate", "2026-08-01") + builder.add_parameter("schemeKey", "SCHEME-1") + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.create_invoice() + + assert exc.value.parameter_name == "Debtor" + + +def test_create_invoice_posts_invoice_and_currency_as_top_level_fields(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "creditmanagement-key-124", "Status": {"Code": {"Code": 190}}, "Services": []}, + ) + ) + + builder = CreditManagementBuilder(client) + builder.invoice("INV-001") + builder.currency("EUR") + builder.add_parameter("invoiceAmount", "100.00") + builder.add_parameter("dueDate", "2026-08-01") + builder.add_parameter("schemeKey", "SCHEME-1") + builder.add_parameter("code", "DEBTOR-001", "Debtor") + response = builder.create_invoice() + + assert response.key == "creditmanagement-key-124" + assert recorded_action(mock_strategy) == "CreateInvoice" + + request = recorded_request(mock_strategy) + assert request["Invoice"] == "INV-001" + assert request["Currency"] == "EUR" + + service = request["Services"]["ServiceList"][0] + params = {(p["Name"], p["GroupType"]): p["Value"] for p in service["Parameters"]} + assert params[("Code", "Debtor")] == "DEBTOR-001" + assert ("Invoice", "") not in params + assert ("Currency", "") not in params + + +def test_get_allowed_service_parameters_add_or_update_debtor_declares_groups(client): + builder = CreditManagementBuilder(client) + params = builder.get_allowed_service_parameters("AddOrUpdateDebtor") + + assert set(params.keys()) == { + "Debtor", + "Person", + "Company", + "Address", + "Email", + "Phone", + } + for name, config in params.items(): + assert config["type"] is dict + assert config["required"] is (name == "Debtor") + + +def test_get_allowed_service_parameters_debtor_info_declares_required_debtor_group(client): + builder = CreditManagementBuilder(client) + params = builder.get_allowed_service_parameters("DebtorInfo") + + assert set(params.keys()) == {"Debtor"} + assert params["Debtor"]["type"] is dict + assert params["Debtor"]["required"] is True + + +@pytest.mark.parametrize("action", ["ResumeDebtorFile", "PauseDebtorFile"]) +def test_get_allowed_service_parameters_debtor_file_actions_require_debtor_file_guid( + client, action +): + builder = CreditManagementBuilder(client) + params = builder.get_allowed_service_parameters(action) + + assert set(params.keys()) == {"debtorFileGuid"} + assert params["debtorFileGuid"]["required"] is True + + +def test_add_or_update_debtor_posts_grouped_params_to_data_request(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + { + "Key": "creditmanagement-key-456", + "Status": {"Code": {"Code": 190}}, + "Services": [], + }, + ) + ) + + builder = CreditManagementBuilder(client) + builder.add_parameter("code", "DEBTOR-001", "Debtor") + builder.add_parameter("firstName", "John", "Person") + builder.add_parameter("email", "john@example.com", "Email") + response = builder.add_or_update_debtor() + + assert response.key == "creditmanagement-key-456" + assert recorded_action(mock_strategy) == "AddOrUpdateDebtor" + + service = recorded_request(mock_strategy)["Services"]["ServiceList"][0] + params = {(p["Name"], p["GroupType"], p["GroupID"]): p["Value"] for p in service["Parameters"]} + assert params[("Code", "Debtor", "")] == "DEBTOR-001" + assert params[("Firstname", "Person", "")] == "John" + assert params[("Email", "Email", "")] == "john@example.com" + + +def test_add_or_update_debtor_raises_when_debtor_group_missing(client): + builder = CreditManagementBuilder(client) + builder.add_parameter("firstName", "John", "Person") + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.add_or_update_debtor() + + assert exc.value.parameter_name == "Debtor" + + +def test_debtor_info_posts_debtor_group_to_data_request(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + { + "Key": "creditmanagement-key-789", + "Status": {"Code": {"Code": 190}}, + "Services": [ + { + "Name": "CreditManagement3", + "Action": "DebtorInfo", + "Parameters": [{"Name": "DebtorKey", "Value": "DEBK-001"}], + } + ], + }, + ) + ) + + builder = CreditManagementBuilder(client) + builder.add_parameter("code", "DEBTOR-001", "Debtor") + response = builder.debtor_info() + + assert response.get_service_parameter("DebtorKey") == "DEBK-001" + assert recorded_action(mock_strategy) == "DebtorInfo" + + service = recorded_request(mock_strategy)["Services"]["ServiceList"][0] + params = {(p["Name"], p["GroupType"], p["GroupID"]): p["Value"] for p in service["Parameters"]} + # DebtorInfo maps the debtor code to wire name "Debtorcode" (not "Code", + # unlike AddOrUpdateDebtor/CreateInvoice). + assert params[("Debtorcode", "Debtor", "")] == "DEBTOR-001" + + +def test_debtor_info_raises_when_debtor_group_missing(client): + builder = CreditManagementBuilder(client) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.debtor_info() + + assert exc.value.parameter_name == "Debtor" + + +def test_build_debtorinfo_directly_renames_debtor_code_without_calling_debtor_info(client): + # The rename must live in build() itself, not only in debtor_info(), so + # callers who go through build("DebtorInfo") or execute_action("DebtorInfo") + # directly still get the correct wire name. + builder = CreditManagementBuilder(client) + builder.add_parameter("code", "DEBTOR-001", "Debtor") + + payload = builder.build("DebtorInfo") + + service = payload.services.services[0] + params = {(p.name, p.group_type): p.value for p in service.parameters} + assert params[("Debtorcode", "Debtor")] == "DEBTOR-001" + + +def test_build_debtorinfo_rename_is_idempotent_across_repeated_build_calls(client): + builder = CreditManagementBuilder(client) + builder.add_parameter("code", "DEBTOR-001", "Debtor") + + builder.build("DebtorInfo") + payload = builder.build("DebtorInfo") + + service = payload.services.services[0] + params = [(p.name, p.group_type) for p in service.parameters] + assert params.count(("Debtorcode", "Debtor")) == 1 + + +def test_build_debtorinfo_then_addorupdatedebtor_does_not_leak_renamed_parameter_name(client): + # build("DebtorInfo") must not mutate the builder's own parameter list in + # place: a subsequent build("AddOrUpdateDebtor") on the SAME builder needs + # "Code" on the wire, not the "Debtorcode" rename DebtorInfo applies. + builder = CreditManagementBuilder(client) + builder.add_parameter("code", "DEBTOR-001", "Debtor") + + builder.build("DebtorInfo") + payload = builder.build("AddOrUpdateDebtor") + + service = payload.services.services[0] + params = {(p.name, p.group_type): p.value for p in service.parameters} + assert params[("Code", "Debtor")] == "DEBTOR-001" + assert ("Debtorcode", "Debtor") not in params + + +def test_build_addorupdatedebtor_then_debtorinfo_still_renames_correctly(client): + # Reverse order: an unaffected action first must not prevent DebtorInfo's + # own rename from applying afterwards. + builder = CreditManagementBuilder(client) + builder.add_parameter("code", "DEBTOR-001", "Debtor") + + builder.build("AddOrUpdateDebtor") + payload = builder.build("DebtorInfo") + + service = payload.services.services[0] + params = {(p.name, p.group_type): p.value for p in service.parameters} + assert params[("Debtorcode", "Debtor")] == "DEBTOR-001" + assert ("Code", "Debtor") not in params + + +@pytest.mark.parametrize( + "method_name,action", + [ + ("resume_debtor_file", "ResumeDebtorFile"), + ("pause_debtor_file", "PauseDebtorFile"), + ], +) +def test_debtor_file_actions_post_debtor_file_guid_to_data_request( + client, mock_strategy, method_name, action +): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "creditmanagement-key-321", "Status": {"Code": {"Code": 190}}, "Services": []}, + ) + ) + + builder = CreditManagementBuilder(client) + builder.add_parameter("debtorFileGuid", "FILE-GUID-001") + response = getattr(builder, method_name)() + + assert response.key == "creditmanagement-key-321" + assert recorded_action(mock_strategy) == action + + service = recorded_request(mock_strategy)["Services"]["ServiceList"][0] + params = {p["Name"]: p["Value"] for p in service["Parameters"]} + assert params["Debtorfileguid"] == "FILE-GUID-001" + + +@pytest.mark.parametrize("method_name", ["resume_debtor_file", "pause_debtor_file"]) +def test_debtor_file_actions_raise_when_debtor_file_guid_missing(client, method_name): + builder = CreditManagementBuilder(client) + + with pytest.raises(RequiredParameterMissingError) as exc: + getattr(builder, method_name)() + + assert exc.value.parameter_name == "debtorFileGuid" + + +@pytest.mark.parametrize("action", ["PauseInvoice", "UnPauseInvoice", "InvoiceInfo"]) +def test_get_allowed_service_parameters_invoice_actions_declare_no_service_parameters( + client, action +): + # invoice is a top-level request field for these actions, not a service + # parameter, so there is nothing left to declare. + builder = CreditManagementBuilder(client) + params = builder.get_allowed_service_parameters(action) + + assert params == {} + + +@pytest.mark.parametrize( + "method_name,action", + [ + ("pause_invoice", "PauseInvoice"), + ("unpause_invoice", "UnPauseInvoice"), + ("invoice_info", "InvoiceInfo"), + ], +) +def test_invoice_actions_post_invoice_as_top_level_field( + client, mock_strategy, method_name, action +): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "creditmanagement-key-654", "Status": {"Code": {"Code": 190}}, "Services": []}, + ) + ) + + builder = CreditManagementBuilder(client) + builder.invoice("INV-001") + response = getattr(builder, method_name)() + + assert response.key == "creditmanagement-key-654" + assert recorded_action(mock_strategy) == action + + request = recorded_request(mock_strategy) + assert request["Invoice"] == "INV-001" + + +def test_get_allowed_service_parameters_create_credit_note_declares_fields(client): + builder = CreditManagementBuilder(client) + params = builder.get_allowed_service_parameters("CreateCreditNote") + + assert set(params.keys()) == { + "originalInvoiceNumber", + "invoiceDate", + "invoiceAmount", + "invoiceAmountVAT", + "Debtor", + } + assert params["originalInvoiceNumber"]["required"] is True + assert params["invoiceDate"]["required"] is True + assert params["invoiceAmount"]["required"] is True + assert params["invoiceAmountVAT"]["required"] is False + assert params["Debtor"]["type"] is dict + assert params["Debtor"]["required"] is True + + +def test_create_credit_note_posts_invoice_top_level_and_debtor_group_to_data_request( + client, mock_strategy +): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "creditmanagement-key-987", "Status": {"Code": {"Code": 190}}, "Services": []}, + ) + ) + + builder = CreditManagementBuilder(client) + builder.invoice("CN-001") + builder.add_parameter("originalInvoiceNumber", "INV-001") + builder.add_parameter("invoiceDate", "2026-07-16") + builder.add_parameter("invoiceAmount", 10.00) + builder.add_parameter("code", "DEBTOR-001", "Debtor") + response = builder.create_credit_note() + + assert response.key == "creditmanagement-key-987" + assert recorded_action(mock_strategy) == "CreateCreditNote" + + request = recorded_request(mock_strategy) + assert request["Invoice"] == "CN-001" + + service = request["Services"]["ServiceList"][0] + params = {(p["Name"], p["GroupType"]): p["Value"] for p in service["Parameters"]} + assert params[("Originalinvoicenumber", "")] == "INV-001" + assert params[("Invoicedate", "")] == "2026-07-16" + assert params[("Invoiceamount", "")] == "10.0" + assert params[("Code", "Debtor")] == "DEBTOR-001" + + +def test_create_credit_note_raises_when_debtor_group_missing(client): + builder = CreditManagementBuilder(client) + builder.invoice("CN-001") + builder.add_parameter("originalInvoiceNumber", "INV-001") + builder.add_parameter("invoiceDate", "2026-07-16") + builder.add_parameter("invoiceAmount", 10.00) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.create_credit_note() + + assert exc.value.parameter_name == "Debtor" + + +def test_get_allowed_service_parameters_add_or_update_product_lines_declares_fields(client): + builder = CreditManagementBuilder(client) + params = builder.get_allowed_service_parameters("AddOrUpdateProductLines") + + assert set(params.keys()) == {"invoiceKey", "ProductLine"} + assert params["invoiceKey"]["required"] is True + assert params["ProductLine"]["type"] is dict + assert params["ProductLine"]["required"] is True + + +def test_add_or_update_product_lines_posts_indexed_grouped_articles(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "creditmanagement-key-111", "Status": {"Code": {"Code": 190}}, "Services": []}, + ) + ) + + builder = CreditManagementBuilder(client) + builder.add_parameter("invoiceKey", "INVK-001") + response = builder.add_or_update_product_lines( + articles=[ + { + "identifier": "SKU-1", + "description": "Widget", + "quantity": "2", + "price": "10.00", + "type": "Regular", + "totalAmount": "20.00", + "totalVat": "4.20", + "vatPercentage": "21", + }, + {"identifier": "SKU-2", "description": "Gadget", "quantity": "1", "price": "25.00"}, + ] + ) + + assert response.key == "creditmanagement-key-111" + assert recorded_action(mock_strategy) == "AddOrUpdateProductLines" + + service = recorded_request(mock_strategy)["Services"]["ServiceList"][0] + params = {(p["Name"], p["GroupType"], p["GroupID"]): p["Value"] for p in service["Parameters"]} + assert params[("Invoicekey", "", "")] == "INVK-001" + assert params[("Productid", "ProductLine", "1")] == "SKU-1" + assert params[("Productname", "ProductLine", "1")] == "Widget" + assert params[("Quantity", "ProductLine", "1")] == "2" + assert params[("Priceperunit", "ProductLine", "1")] == "10.00" + assert params[("Type", "ProductLine", "1")] == "Regular" + assert params[("Totalamount", "ProductLine", "1")] == "20.00" + assert params[("Totalvat", "ProductLine", "1")] == "4.20" + assert params[("Vatpercentage", "ProductLine", "1")] == "21" + assert params[("Productid", "ProductLine", "2")] == "SKU-2" + assert params[("Productname", "ProductLine", "2")] == "Gadget" + assert params[("Quantity", "ProductLine", "2")] == "1" + assert params[("Priceperunit", "ProductLine", "2")] == "25.00" + + +def test_add_or_update_product_lines_raises_when_articles_missing(client): + builder = CreditManagementBuilder(client) + builder.add_parameter("invoiceKey", "INVK-001") + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.add_or_update_product_lines() + + assert exc.value.parameter_name == "ProductLine" + + +def test_get_allowed_service_parameters_create_combined_invoice_declares_fields(client): + builder = CreditManagementBuilder(client) + params = builder.get_allowed_service_parameters("CreateCombinedInvoice") + + assert set(params.keys()) == { + "invoiceDate", + "dueDate", + "invoiceAmount", + "invoiceAmountVAT", + "schemeKey", + "maxStepIndex", + "allowedServices", + "applyStartRecurrent", + "Debtor", + "Person", + "Company", + "Address", + "Email", + "Phone", + } + + required = {"invoiceAmount", "dueDate", "schemeKey", "Debtor"} + for name, config in params.items(): + assert config["required"] is (name in required), f"{name} required flag mismatch" + + +def test_create_combined_invoice_returns_combinable_service_without_posting(client, mock_strategy): + builder = CreditManagementBuilder(client) + builder.invoice("INV-001") + builder.add_parameter("invoiceAmount", "100.00") + builder.add_parameter("dueDate", "2026-08-01") + builder.add_parameter("schemeKey", "SCHEME-1") + builder.add_parameter("code", "DEBTOR-001", "Debtor") + + combinable = builder.create_combined_invoice() + + assert isinstance(combinable, CombinableService) + assert len(combinable.services) == 1 + service = combinable.services[0] + assert service.name == "CreditManagement3" + assert service.action == "CreateCombinedInvoice" + assert mock_strategy.calls == [] + + +def test_create_combined_invoice_service_parameters_are_not_mutated_by_later_add_parameter( + client, +): + builder = CreditManagementBuilder(client) + builder.invoice("INV-001") + builder.add_parameter("invoiceAmount", "100.00") + builder.add_parameter("dueDate", "2026-08-01") + builder.add_parameter("schemeKey", "SCHEME-1") + builder.add_parameter("code", "DEBTOR-001", "Debtor") + + combinable = builder.create_combined_invoice() + service = combinable.services[0] + param_count_before = len(service.parameters) + + # Mutating the builder after the combined service was "already built" + # must not leak into the returned service's parameter list. + builder.add_parameter("extraField", "extra-value") + + assert len(service.parameters) == param_count_before + + +def test_create_combined_invoice_raises_when_debtor_group_missing(client): + builder = CreditManagementBuilder(client) + builder.invoice("INV-001") + builder.add_parameter("invoiceAmount", "100.00") + builder.add_parameter("dueDate", "2026-08-01") + builder.add_parameter("schemeKey", "SCHEME-1") + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.create_combined_invoice() + + assert exc.value.parameter_name == "Debtor" + + +def test_get_allowed_service_parameters_create_payment_plan_declares_fields(client): + builder = CreditManagementBuilder(client) + params = builder.get_allowed_service_parameters("CreatePaymentPlan") + + assert set(params.keys()) == { + "includedInvoiceKey", + "dossierNumber", + "startDate", + "interval", + "paymentPlanCostAmount", + "recipientEmail", + "installmentCount", + "installmentAmount", + } + + required = { + "includedInvoiceKey", + "dossierNumber", + "startDate", + "interval", + "paymentPlanCostAmount", + "recipientEmail", + } + for name, config in params.items(): + assert config["required"] is (name in required), f"{name} required flag mismatch" + + +def test_get_allowed_service_parameters_create_payment_plan_excludes_description(client): + # description is a TOP-LEVEL request field for CreatePaymentPlan (like + # invoice/currency for the invoice actions), never a service parameter — + # the gateway rejects it with 491 "Description is not a known parameter" + # when sent as one. + builder = CreditManagementBuilder(client) + params = builder.get_allowed_service_parameters("CreatePaymentPlan") + + assert "description" not in params + + +def test_create_payment_plan_posts_to_data_request(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "creditmanagement-key-222", "Status": {"Code": {"Code": 190}}, "Services": []}, + ) + ) + + builder = CreditManagementBuilder(client) + builder.description("3-month plan") + builder.add_parameter("includedInvoiceKey", "INVK-001") + builder.add_parameter("dossierNumber", "DOSSIER-1") + builder.add_parameter("startDate", "2026-09-01") + builder.add_parameter("interval", "Month") + builder.add_parameter("paymentPlanCostAmount", "5.00") + builder.add_parameter("recipientEmail", "debtor@example.com") + builder.add_parameter("installmentCount", "3") + response = builder.create_payment_plan() + + assert response.key == "creditmanagement-key-222" + assert recorded_action(mock_strategy) == "CreatePaymentPlan" + + request = recorded_request(mock_strategy) + assert request["Description"] == "3-month plan" + + service = request["Services"]["ServiceList"][0] + params = {p["Name"]: p["Value"] for p in service["Parameters"]} + assert params["Includedinvoicekey"] == "INVK-001" + assert params["Dossiernumber"] == "DOSSIER-1" + assert params["Startdate"] == "2026-09-01" + assert params["Interval"] == "Month" + assert params["Paymentplancostamount"] == "5.00" + assert params["Recipientemail"] == "debtor@example.com" + assert params["Installmentcount"] == "3" + assert "Description" not in params + + +def test_create_payment_plan_raises_when_included_invoice_key_missing(client): + builder = CreditManagementBuilder(client) + builder.description("3-month plan") + builder.add_parameter("dossierNumber", "DOSSIER-1") + builder.add_parameter("startDate", "2026-09-01") + builder.add_parameter("interval", "Month") + builder.add_parameter("paymentPlanCostAmount", "5.00") + builder.add_parameter("recipientEmail", "debtor@example.com") + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.create_payment_plan() + + assert exc.value.parameter_name == "includedInvoiceKey" + + +def test_get_allowed_service_parameters_terminate_payment_plan_declares_fields(client): + builder = CreditManagementBuilder(client) + params = builder.get_allowed_service_parameters("TerminatePaymentPlan") + + assert set(params.keys()) == {"includedInvoiceKey"} + assert params["includedInvoiceKey"]["required"] is True + + +def test_terminate_payment_plan_posts_to_data_request(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "creditmanagement-key-333", "Status": {"Code": {"Code": 190}}, "Services": []}, + ) + ) + + builder = CreditManagementBuilder(client) + builder.add_parameter("includedInvoiceKey", "INVK-001") + response = builder.terminate_payment_plan() + + assert response.key == "creditmanagement-key-333" + assert recorded_action(mock_strategy) == "TerminatePaymentPlan" + + service = recorded_request(mock_strategy)["Services"]["ServiceList"][0] + params = {p["Name"]: p["Value"] for p in service["Parameters"]} + assert params["Includedinvoicekey"] == "INVK-001" + + +def test_terminate_payment_plan_raises_when_included_invoice_key_missing(client): + builder = CreditManagementBuilder(client) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.terminate_payment_plan() + + assert exc.value.parameter_name == "includedInvoiceKey" diff --git a/tests/unit/builders/test_base_builder.py b/tests/unit/builders/test_base_builder.py index 91d39e7..7925d22 100644 --- a/tests/unit/builders/test_base_builder.py +++ b/tests/unit/builders/test_base_builder.py @@ -240,6 +240,33 @@ def test_from_dict_service_parameters_nested_dict_becomes_grouped_parameters(): ] +def test_from_dict_service_parameters_dict_vs_list_group_type_casing_asymmetry(): + # add_parameter has two branches with deliberately different casing rules: + # - scalar/dict branch: _upper_first(group_type) preserves internal case + # ("billingCustomer" -> "BillingCustomer"). + # - list-of-dicts branch: key.capitalize() flattens internal case + # ("billingCustomer" -> "Billingcustomer"). + # The list branch is left alone on purpose: changing it would alter In3's + # existing verified wire format (In3 declares billingCustomer as type + # list everywhere), which is out of scope here. This test pins both + # behaviors so a future change to either branch is a conscious decision. + dict_builder = populate_required_fields(_make_builder(), amount=10.50) + dict_builder.from_dict({"service_parameters": {"billingCustomer": {"firstName": "John"}}}) + dict_request = dict_builder.build(validate=False).to_dict() + dict_params = dict_request["Services"]["ServiceList"][0]["Parameters"] + assert dict_params == [ + {"Name": "Firstname", "GroupType": "BillingCustomer", "GroupID": "", "Value": "John"} + ] + + list_builder = populate_required_fields(_make_builder(), amount=10.50) + list_builder.from_dict({"service_parameters": {"billingCustomer": [{"firstName": "John"}]}}) + list_request = list_builder.build(validate=False).to_dict() + list_params = list_request["Services"]["ServiceList"][0]["Parameters"] + assert list_params == [ + {"Name": "Firstname", "GroupType": "Billingcustomer", "GroupID": "1", "Value": "John"} + ] + + def test_from_dict_ignores_unknown_field_silently(): builder = populate_required_fields(_make_builder(), amount=10.50) builder.from_dict({"unknown_field": "surprise", "another_mystery": 123}) @@ -288,6 +315,22 @@ def test_add_parameter_grouped_sets_group_type_and_group_id(): ] +def test_add_parameter_grouped_preserves_case_of_multi_word_group_type(): + builder = populate_required_fields(_make_builder(), amount=10.50) + builder.add_parameter("productId", "SKU-1", group_type="ProductLine", group_id="1") + + request = builder.build(validate=False).to_dict() + service = request["Services"]["ServiceList"][0] + assert service["Parameters"] == [ + { + "Name": "Productid", + "GroupType": "ProductLine", + "GroupID": "1", + "Value": "SKU-1", + } + ] + + def test_add_parameter_boolean_values_are_lowercased_strings(): builder = populate_required_fields(_make_builder(), amount=10.50) builder.add_parameter("enabled", True)