From 9f5761197984666e8445373460c1ca26b4c1efec Mon Sep 17 00:00:00 2001 From: Howard Ranyard Date: Thu, 26 Mar 2026 20:38:36 +0000 Subject: [PATCH] Add GOV.UK Pay integration --- docs/customising/govuk-pay.md | 119 ++++++ perllib/FixMyStreet/Roles/Cobrand/GOVUKPay.pm | 226 +++++++++++ perllib/Integrations/GOVUKPay.pm | 231 ++++++++++++ t/app/controller/waste_govukpay.t | 355 ++++++++++++++++++ t/integrations/govukpay.t | 170 +++++++++ 5 files changed, 1101 insertions(+) create mode 100644 docs/customising/govuk-pay.md create mode 100644 perllib/FixMyStreet/Roles/Cobrand/GOVUKPay.pm create mode 100644 perllib/Integrations/GOVUKPay.pm create mode 100644 t/app/controller/waste_govukpay.t create mode 100644 t/integrations/govukpay.t diff --git a/docs/customising/govuk-pay.md b/docs/customising/govuk-pay.md new file mode 100644 index 00000000000..eadfff65e41 --- /dev/null +++ b/docs/customising/govuk-pay.md @@ -0,0 +1,119 @@ +--- +layout: page +title: GOV.UK Pay integration +--- + +# GOV.UK Pay integration + +GOV.UK Pay can be used as a payment gateway for WasteWorks services such as +garden waste subscriptions and bulky collections. It follows the same +architecture as the existing SCP (Capita) and Adelante integrations. + +## Files + +There are two key files: + +- **`perllib/Integrations/GOVUKPay.pm`** — A standalone HTTP client for the GOV.UK Pay REST API. This is purely responsible for talking to GOV.UK Pay: creating payments, fetching payment details, checking status, and searching. It handles authentication with your API key, JSON serialization, error handling, and retry logic. You'd use this directly if you need to do anything custom with GOV.UK Pay outside the normal payment flow (e.g., refunds, searching past payments). + +- **`perllib/FixMyStreet/Roles/Cobrand/GOVUKPay.pm`** — A Moo::Role that wires GOVUKPay.pm into FixMyStreet's payment system. It implements the standard payment gateway interface (`waste_cc_has_redirect`, `waste_cc_get_redirect_url`, `waste_cc_check_payment_status`, etc.) so that cobrands can swap payment providers without changing their code. If you're implementing GOV.UK Pay for a new council, you compose this role into your cobrand. If you're debugging payment issues, most of the logic lives here. + +## Cobrand setup + +To use GOV.UK Pay in a cobrand, add the role and implement the required +`waste_cc_payment_reference` method: + +```perl +package FixMyStreet::Cobrand::YourCouncil; +use parent 'FixMyStreet::Cobrand::UKCouncils'; +use Moo; + +with 'FixMyStreet::Roles::Cobrand::GOVUKPay'; +with 'FixMyStreet::Roles::Cobrand::Waste'; + +sub waste_cc_payment_reference { + my ($self, $p) = @_; + return 'FMS-' . $p->id; +} + +1; +``` + +## Configuration + +Add the following to `conf/general.yml`: + +```yaml +COBRAND_FEATURES: + waste: + yourcouncil: 1 + payment_gateway: + yourcouncil: + govukpay_api_key: 'your-live-or-test-api-key' + govukpay_api_url: 'https://publicapi.payments.service.gov.uk' + govukpay_description_prefix: 'Your Council' + log_ident: 'yourcouncil_govukpay' +``` + +**What each setting does:** + +- **`govukpay_api_key`** — Your authentication token for GOV.UK Pay. + +- **`govukpay_api_url`** — The base URL for the GOV.UK Pay REST API. + +- **`govukpay_description_prefix`** — Prefix used when constructing the GOV.UK Pay payment description (for example, Your Council: Garden Subscription - New). + +- **`log_ident`** — Used in logs to distinguish GOV.UK Pay activity from other payment providers (SCP, Adelante, etc.). + +**For testing:** + +Use a **sandbox API key** from your GOV.UK Pay admin console. It starts with `api_test_` and won't charge real card numbers. Test payment details (card number `4111111111111111`, any future date, any CVC) are documented at . + +## Payment flow + +Here's what happens when a user subscribes to garden waste or requests a bulky collection: + +**User initiates payment:** +1. User fills in the waste form and submits (e.g., garden subscription with payment option) +2. Waste controller checks that payment is required via `waste_cc_has_redirect()` +3. If yes, controller calls `waste_cc_get_redirect_url()` to start the payment + +**GOV.UK Pay creates the payment:** +4. This method calls `Integrations::GOVUKPay->create_payment()` with a unique reference (usually from `waste_cc_payment_reference`), amount, and description +5. GOV.UK Pay API returns a payment object with a `next_url` (their hosted payment page) +6. We store the payment ID in report metadata under the `scpReference` key (shared key used by multiple payment providers) +7. User is redirected to the GOV.UK Pay payment page + +**User completes payment:** +8. User enters their card details on GOV.UK Pay's secure form +9. After payment (success or failure), user is redirected to `/waste/pay_complete/{report_id}/{token}` +10. Token is used to look up which report this is for (as a security check) + +**We verify payment succeeded:** +11. Waste controller calls `waste_cc_check_payment_status()` to confirm the payment really went through via `Integrations::GOVUKPay->get_payment_details()` (GET `/v1/payments/{id}`) +12. If status is `success`, we call `waste_confirm_payment()` to mark the report as paid +13. If status is `failed` or `cancelled`, user sees an error and can try again + +**Background reconciliation:** +- `perllib/FixMyStreet/Script/Waste/CheckPayments.pm` runs periodically (via cron) to catch any reports that started payment but never completed the callback (network timeout, browser crash, etc.). It checks GOV.UK Pay for any payments that succeeded after the fact and confirms those reports. + + +## GOV.UK Pay API reference + +| Endpoint | Method | Consistency | +|---|---|---| +| `/v1/payments` | POST | Strongly consistent | +| `/v1/payments/{id}` | GET | Strongly consistent | +| `/v1/payments` | GET (search) | Eventually consistent | + +**Strongly consistent** means the response always reflects the very latest +state of the payment — if a user just completed payment, the API will +immediately return the updated status. This is what we use on the +`pay_complete` callback to verify the result. + +**Eventually consistent** means the response may be briefly out of date. +The search endpoint can take a short time to reflect recent changes, so it is +best for reporting or ad-hoc bulk lookups where a small delay is acceptable. +The current waste payment callback and reconciliation flows do not use this +endpoint; they use GET `/v1/payments/{id}` for per-payment status checks. + +Full docs: diff --git a/perllib/FixMyStreet/Roles/Cobrand/GOVUKPay.pm b/perllib/FixMyStreet/Roles/Cobrand/GOVUKPay.pm new file mode 100644 index 00000000000..e7341660632 --- /dev/null +++ b/perllib/FixMyStreet/Roles/Cobrand/GOVUKPay.pm @@ -0,0 +1,226 @@ +=head1 NAME + +FixMyStreet::Roles::Cobrand::GOVUKPay - GOV.UK Pay payment gateway role + +=head1 SYNOPSIS + +In your cobrand: + + package FixMyStreet::Cobrand::YourCobrand; + use Moo; + with 'FixMyStreet::Roles::Cobrand::GOVUKPay'; + + sub waste_cc_payment_reference { + my ($self, $p) = @_; + return 'REF-' . $p->id; + } + +=head1 DESCRIPTION + +A Moo::Role providing GOV.UK Pay integration for FixMyStreet waste/garden +subscriptions and similar payment flows. Follows the same interface as +L and L. + +Configuration is read from C<< $cobrand->feature('payment_gateway') >> +and should include: + + COBRAND_FEATURES: + payment_gateway: + yourcobrand: + govukpay_api_key: 'your-api-key' + govukpay_api_url: 'https://publicapi.payments.service.gov.uk' + govukpay_description_prefix: 'FixMyStreet' # optional + +=cut + +package FixMyStreet::Roles::Cobrand::GOVUKPay; + +use Moo::Role; +use Try::Tiny; +use Integrations::GOVUKPay; + +# Cobrands consuming this role must provide a method that returns a +# payment reference string for a given Problem. +requires 'waste_cc_payment_reference'; + +=head2 waste_cc_has_redirect + +Returns 1 — GOV.UK Pay always redirects the payer to their hosted page. + +=cut + +sub waste_cc_has_redirect { 1 } + +=head2 _govukpay_config + +Returns the GOV.UK Pay subset of the payment_gateway config. + +=cut + +sub _govukpay_config { + my $self = shift; + my $cfg = $self->feature('payment_gateway') || {}; + return { + api_key => $cfg->{govukpay_api_key}, + api_url => $cfg->{govukpay_api_url} || 'https://publicapi.payments.service.gov.uk', + log_ident => $cfg->{log_ident} || 'govukpay', + }; +} + +=head2 _govukpay_client + +Returns a configured L instance. + +=cut + +sub _govukpay_client { + my $self = shift; + return Integrations::GOVUKPay->new({ + config => $self->_govukpay_config, + }); +} + +=head2 waste_cc_get_redirect_url($c, $back) + +Creates a payment via GOV.UK Pay and returns the hosted payment page URL. +Stores C in the report's extra metadata for later lookup. + +=cut + +sub waste_cc_get_redirect_url { + my ($self, $c, $back) = @_; + + my $p = $c->stash->{report}; + + # Work out the amount — pro_rata takes precedence over payment + my $amount = $p->get_extra_field_value('pro_rata'); + $amount = $p->get_extra_field_value('payment') unless $amount; + my $admin_fee = $p->get_extra_field_value('admin_fee') || 0; + my $total = ($amount || 0) + $admin_fee; + + unless ($total && $total > 0) { + $c->stash->{error} = 'No payment amount found'; + return undef; + } + + # Generate redirect token for verification on return + my $redirect_id = mySociety::AuthToken::random_token(); + $p->update_extra_metadata(redirect_id => $redirect_id); + + my $reference = $self->waste_cc_payment_reference($p); + my $cfg = $self->feature('payment_gateway') || {}; + my $prefix = $cfg->{govukpay_description_prefix} || 'FixMyStreet'; + my $description = "$prefix: " . $p->title; + + # Build return / back URLs + my $return_url = $c->uri_for_action('/waste/pay_complete', [ $p->id, $redirect_id ]) . ''; + + + my $result = try { + my $payment = $self->_govukpay_client; + $payment->create_payment({ + amount => $total, + reference => $reference, + description => $description, + return_url => $return_url, + email => $p->user->email, + metadata => { + report_id => $p->id . '', + category => $p->category, + }, + }); + } catch { + $c->stash->{error} = $_; + return undef; + }; + return undef unless $result; + + # Store GOV.UK Pay payment_id for later status queries. + $p->update_extra_metadata(scpReference => $result->{payment_id}); + + return $result->{next_url}; +} + +=head2 cc_check_payment_status($govukpay_id) + +Queries GOV.UK Pay for the status of the given payment ID. + +Returns C<($error, $payment_id)> where C<$payment_id> is the GOV.UK Pay +payment_id (used as the transaction reference) on success, or C<$error> +is a descriptive string on failure. + +=cut + +sub cc_check_payment_status { + my ($self, $govukpay_id) = @_; + + my ($data, $error); + + my $details = try { + $self->_govukpay_client->get_payment_details($govukpay_id); + } catch { + $error = $_; + }; + return ($error, undef) if $error; + + my $state = $details->{state} || {}; + if ($state->{status} eq 'success') { + $data = $details; + } elsif ($state->{finished}) { + # Finished but not success → cancelled, failed, error + $error = $state->{status} || 'payment_failed'; + } else { + # Still in progress + $error = 'in_progress'; + } + + return ($error, $data); +} + +=head2 cc_check_payment_and_update($reference, $p) + +Checks payment status via GOV.UK Pay. +C<$reference> is the GOV.UK Pay payment ID (stored as scpReference +on the report). + +Returns C<($error, $payment_reference)>. + +=cut + +sub cc_check_payment_and_update { + my ($self, $reference, $p) = @_; + + my ($error, $data) = $self->cc_check_payment_status($reference); + return (undef, $data->{payment_id}) if $data; + return ($error, undef); +} + +=head2 waste_cc_check_payment_status($c, $p) + +Called by the Waste controller on C. Fetches the payment +status from GOV.UK Pay, updates the report, and returns the payment +reference on success or C on failure (with C<$c->stash->{error}> +set). + +=cut + +sub waste_cc_check_payment_status { + my ($self, $c, $p) = @_; + + my $govukpay_id = $p->get_extra_metadata('scpReference'); + $c->detach('/page_error_404_not_found') unless $govukpay_id; + + my ($error, $id) = $self->cc_check_payment_and_update($govukpay_id, $p); + if ($error) { + if ($error eq 'in_progress') { + # Payment hasn't completed yet — show a retry page + $c->stash->{retry_confirmation} = 1; + } + $c->stash->{error} = $error; + return undef; + } + + return $id; +} + +1; diff --git a/perllib/Integrations/GOVUKPay.pm b/perllib/Integrations/GOVUKPay.pm new file mode 100644 index 00000000000..f8bd1d845a4 --- /dev/null +++ b/perllib/Integrations/GOVUKPay.pm @@ -0,0 +1,231 @@ +=head1 NAME + +Integrations::GOVUKPay - GOV.UK Pay REST API client + +=head1 SYNOPSIS + + my $pay = Integrations::GOVUKPay->new({ + config => { + api_key => 'your-api-key', + api_url => 'https://publicapi.payments.service.gov.uk', + log_ident => 'govukpay', + }, + }); + + # Create a payment + my $result = $pay->create_payment({ + amount => 2500, # pence + reference => 'ORDER-001', + description => 'Garden waste subscription', + return_url => 'https://example.com/pay_complete/123/token', + }); + + # Query payment status + my $details = $pay->get_payment_details($payment_id); + +=head1 DESCRIPTION + +A thin Perl client for the GOV.UK Pay API, modelled after the existing +Integrations::SCP and Integrations::Adelante modules. Uses LWP::UserAgent +for HTTP and JSON::MaybeXS for serialisation. + +API docs: L + +=cut + +package Integrations::GOVUKPay; + +use Moo; +with 'FixMyStreet::Roles::Syslog'; + +use JSON::MaybeXS; +use LWP::UserAgent; +use HTTP::Request::Common; +use Try::Tiny; + +has config => ( + is => 'ro', + coerce => sub { return {} unless $_[0] }, +); + +has log_ident => ( + is => 'lazy', + default => sub { $_[0]->config->{log_ident} || 'govukpay' }, +); + +has ua => ( + is => 'lazy', + default => sub { + LWP::UserAgent->new( + timeout => 30, + agent => 'FixMyStreet-GOVUKPay/1.0', + ); + }, +); + +has json => ( + is => 'lazy', + default => sub { JSON::MaybeXS->new(utf8 => 1) }, +); + +sub _api_url { + my $self = shift; + return $self->config->{api_url} || 'https://publicapi.payments.service.gov.uk'; +} + +sub _headers { + my $self = shift; + return ( + 'Authorization' => 'Bearer ' . $self->config->{api_key}, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ); +} + +=head2 create_payment(\%args) + +Create a new payment. Required keys in C<%args>: + +=over + +=item amount - amount in pence (integer) + +=item reference - your unique payment reference + +=item description - human-readable description + +=item return_url - URL to redirect payer after completion + +=back + +Optional keys: C, C (hashref). + +Returns a hashref with C and C on success, +or dies with an error message on failure. + +=cut + +sub create_payment { + my ($self, $args) = @_; + + my $payload = { + amount => $args->{amount}, + reference => $args->{reference}, + description => $args->{description}, + return_url => $args->{return_url}, + }; + $payload->{email} = $args->{email} if $args->{email}; + $payload->{metadata} = $args->{metadata} if $args->{metadata}; + + $self->log("create_payment: ref=$args->{reference} amount=$args->{amount}"); + + my $url = $self->_api_url . '/v1/payments'; + my $body = $self->json->encode($payload); + + my $req = HTTP::Request->new('POST', $url); + $req->header('Authorization' => 'Bearer ' . $self->config->{api_key}); + $req->header('Content-Type' => 'application/json'); + $req->header('Accept' => 'application/json'); + $req->content($body); + + $self->log("create_payment URL: $url"); + $self->log("create_payment body: $body"); + + my $response = $self->ua->request($req); + my $data = $self->_decode_response($response, 'create_payment'); + + my $payment_id = $data->{payment_id} + or die "GOV.UK Pay: no payment_id in response\n"; + my $next_url = $data->{_links}{next_url}{href} + or die "GOV.UK Pay: no next_url in response\n"; + + $self->log("create_payment: payment_id=$payment_id"); + + return { + payment_id => $payment_id, + next_url => $next_url, + }; +} + +=head2 get_payment_details($payment_id) + +Fetch the full payment resource. Uses C. +This endpoint is strongly consistent. + +Returns the decoded JSON hashref, or dies on error. + +=cut + +sub get_payment_details { + my ($self, $payment_id) = @_; + + $self->log("get_payment_details: $payment_id"); + + my $url = $self->_api_url . '/v1/payments/' . $payment_id; + my $req = HTTP::Request->new('GET', $url); + $req->header($self->_headers); + + my $response = $self->ua->request($req); + return $self->_decode_response($response, 'get_payment_details'); +} + +=head2 get_payment_status($payment_id) + +Convenience method: returns the status string for a payment. + +Possible values: C, C, C, C, +C, C, C, C. + +=cut + +sub get_payment_status { + my ($self, $payment_id) = @_; + + my $details = $self->get_payment_details($payment_id); + return $details->{state}{status} || 'unknown'; +} + +=head2 search_payments(\%params) + +Search payments using C (eventually consistent). +Passes all keys in C<%params> as query parameters. + +Common parameters: C, C, C, C, +C, C. + +Returns the decoded JSON hashref including C, C, C. + +=cut + +sub search_payments { + my ($self, $params) = @_; + $params ||= {}; + + $self->log("search_payments: " . join(', ', map { "$_=$params->{$_}" } keys %$params)); + + my $uri = URI->new($self->_api_url . '/v1/payments'); + $uri->query_form(%$params); + + my $req = HTTP::Request->new('GET', $uri); + $req->header($self->_headers); + + my $response = $self->ua->request($req); + return $self->_decode_response($response, 'search_payments'); +} + +# Internal: decode an HTTP response or die with details +sub _decode_response { + my ($self, $response, $method) = @_; + + my $content = $response->decoded_content || ''; + $self->log("$method response: " . $response->status_line); + $self->log($content) if $content; + + unless ($response->is_success) { + die "GOV.UK Pay $method failed: " . $response->status_line . " " . $content . "\n"; + } + + return $self->json->decode($content); +} + +1; diff --git a/t/app/controller/waste_govukpay.t b/t/app/controller/waste_govukpay.t new file mode 100644 index 00000000000..4bbf46cd4cc --- /dev/null +++ b/t/app/controller/waste_govukpay.t @@ -0,0 +1,355 @@ +use Test::MockModule; +use Test::MockTime qw(:all); +use FixMyStreet::TestMech; + +# Test the GOVUKPay cobrand role through the Waste controller. +# +# We reuse the Brent cobrand infrastructure (Echo integration, garden waste +# forms, contacts) but override its SCP payment methods with the GOVUKPay +# role implementations. This exercises the full payment flow: +# new subscription → payment redirect → pay_complete → confirmation + +FixMyStreet::App->log->disable('info'); +END { FixMyStreet::App->log->enable('info'); } + +# Mock fetching bank holidays +my $uk = Test::MockModule->new('FixMyStreet::Cobrand::UK'); +$uk->mock('_fetch_url', sub { '{}' }); + +set_fixed_time('2023-01-09T17:00:00Z'); + +my $mech = FixMyStreet::TestMech->new; + +my $body = $mech->create_body_ok(2488, 'Brent', { cobrand => 'brent' }); +my $user = $mech->create_user_ok('govukpay-test@example.net', name => 'Test User'); + +sub create_contact { + my ($params, @extra) = @_; + my $contact = $mech->create_contact_ok( + body => $body, + %$params, + group => ['Waste'], + extra => { type => 'waste' }, + ); + $contact->set_extra_fields( + { code => 'property_id', required => 1, automated => 'hidden_field' }, + { code => 'service_id', required => 0, automated => 'hidden_field' }, + @extra, + ); + $contact->update; +} + +create_contact( + { category => 'Garden Subscription', email => 'garden@example.com' }, + { code => 'Request_Type', required => 1, automated => 'hidden_field' }, + { code => 'Paid_Collection_Container_Type', required => 1, automated => 'hidden_field' }, + { code => 'Paid_Collection_Container_Quantity', required => 1, automated => 'hidden_field' }, + { code => 'Container_Type', required => 0, automated => 'hidden_field' }, + { code => 'Container_Quantity', required => 0, automated => 'hidden_field' }, + { code => 'Payment_Value', required => 1, automated => 'hidden_field' }, + { code => 'current_containers', required => 1, automated => 'hidden_field' }, + { code => 'new_containers', required => 1, automated => 'hidden_field' }, + { code => 'payment', required => 1, automated => 'hidden_field' }, + { code => 'payment_method', required => 1, automated => 'hidden_field' }, + { code => 'email_renewal_reminders_opt_in', required => 0, automated => 'hidden_field' }, +); + +# Suppress SOAP::Result used by some Echo mock paths +package SOAP::Result; +sub result { return $_[0]->{result}; } +sub new { my $c = shift; bless { @_ }, $c; } + +package main; + +# --- Echo service data --- + +sub food_waste_collection { + return { + Id => 1001, + ServiceId => 316, + ServiceName => 'Food waste collection', + ServiceTasks => { ServiceTask => { + Id => 400, + TaskTypeId => 1688, + ServiceTaskSchedules => { ServiceTaskSchedule => [ { + Allocation => { + RoundName => 'Monday ', + RoundGroupName => 'Delta 04 Week 2', + }, + StartDate => { DateTime => '2020-01-01T00:00:00Z' }, + EndDate => { DateTime => '2050-01-01T00:00:00Z' }, + NextInstance => { + CurrentScheduledDate => { DateTime => '2020-06-02T00:00:00Z' }, + OriginalScheduledDate => { DateTime => '2020-06-01T00:00:00Z' }, + }, + LastInstance => { + OriginalScheduledDate => { DateTime => '2020-05-18T00:00:00Z' }, + CurrentScheduledDate => { DateTime => '2020-05-18T00:00:00Z' }, + Ref => { Value => { anyType => [ 456, 789 ] } }, + }, + } ] }, + } }, + }; +} + +sub garden_waste_no_bins { + return [ + food_waste_collection(), + { + Id => 1002, + ServiceId => 317, + ServiceName => 'Garden waste collection', + ServiceTasks => '', + }, + ]; +} + +# --- Override Brent's SCP payment methods with GOVUKPay --- + +require FixMyStreet::Roles::Cobrand::GOVUKPay; + +my $cobrand_mock = Test::MockModule->new('FixMyStreet::Cobrand::Brent'); +for my $method (qw( + waste_cc_has_redirect + waste_cc_get_redirect_url + waste_cc_check_payment_status + cc_check_payment_status + cc_check_payment_and_update + _govukpay_config + _govukpay_client +)) { + my $code = FixMyStreet::Roles::Cobrand::GOVUKPay->can($method); + $cobrand_mock->mock($method, $code) if $code; +} + +# GOVUKPay requires waste_cc_payment_reference (SCP uses a different set) +$cobrand_mock->mock('waste_cc_payment_reference', sub { + my ($self, $p) = @_; + return 'FMS-' . $p->id; +}); + +# --- Mock Integrations::GOVUKPay API calls --- + +my $sent_create_params; + +my $govukpay = Test::MockModule->new('Integrations::GOVUKPay'); +$govukpay->mock('create_payment', sub { + my ($self, $args) = @_; + $sent_create_params = $args; + return { + payment_id => 'govukpay_abc123', + next_url => 'http://example.org/faq', + }; +}); + +my $payment_status = 'success'; +$govukpay->mock('get_payment_details', sub { + my ($self, $payment_id) = @_; + return { + payment_id => $payment_id, + amount => $sent_create_params->{amount} || 5000, + reference => $sent_create_params->{reference} || 'FMS-0', + state => { + status => $payment_status, + finished => ($payment_status eq 'success' ? \1 : \0), + }, + }; +}); + +# Suppress syslog from the integration module +my $syslog_mock = Test::MockModule->new('FixMyStreet::Roles::Syslog'); +$syslog_mock->mock('log', sub {}); + +# --- Mock Echo integration --- + +my $echo = Test::MockModule->new('Integrations::Echo'); +$echo->mock('GetEventsForObject', sub { [] }); +$echo->mock('GetTasks', sub { [] }); +$echo->mock('FindPoints', sub { [ + { + Description => '2 Example Street, Brent, HA0 5HF', + Id => '12345', + SharedRef => { Value => { anyType => 1000000002 } }, + }, +] }); +$echo->mock('GetPointAddress', sub { + return { + Id => 12345, + SharedRef => { Value => { anyType => '1000000002' } }, + PointType => 'PointAddress', + PointAddressType => { Name => 'House' }, + Coordinates => { GeoPoint => { Latitude => 51.55904, Longitude => -0.28168 } }, + Description => '2 Example Street, Brent, ', + }; +}); +$echo->mock('GetServiceUnitsForObject', \&garden_waste_no_bins); + +# --- Tests --- + +FixMyStreet::override_config { + ALLOWED_COBRANDS => 'brent', + MAPIT_URL => 'http://mapit.uk/', + COBRAND_FEATURES => { + echo => { brent => { url => 'http://example.org' } }, + waste => { brent => 1 }, + payment_gateway => { brent => { + ggw_cost => 5000, + govukpay_api_key => 'test_govukpay_key_abc123', + govukpay_api_url => 'https://publicapi.payments.service.gov.uk', + govukpay_description_prefix => 'Brent Council', + } }, + waste_features => { brent => { + dd_disabled => 1, + } }, + anonymous_account => { brent => 'anonymous.customer' }, + }, +}, sub { + + subtest 'GOVUKPay: new garden subscription via credit card' => sub { + $mech->get_ok('/waste/12345/garden'); + $mech->submit_form_ok({ form_number => 1 }); + $mech->submit_form_ok({ with_fields => { existing => 'no' } }); + $mech->submit_form_ok({ with_fields => { + current_bins => 0, + bins_wanted => 1, + name => 'Test McTest', + email => 'govukpay-test@example.net', + } }); + $mech->content_contains('£50.00', 'shows correct cost'); + $mech->content_contains('Continue to payment'); + + # Submit form — should redirect to GOV.UK Pay hosted page + $mech->waste_submit_check({ with_fields => { tandc => 1 } }); + + # Verify the payment creation request + is $sent_create_params->{amount}, 5000, 'correct amount sent to GOV.UK Pay'; + like $sent_create_params->{reference}, qr/^FMS-\d+$/, 'reference follows expected format'; + like $sent_create_params->{description}, qr/Brent Council/, 'description includes council prefix'; + is $sent_create_params->{email}, 'govukpay-test@example.net', 'email forwarded to GOV.UK Pay'; + like $sent_create_params->{return_url}, qr{/waste/pay_complete/\d+/}, 'return_url points to pay_complete'; + + # Extract report from the return URL sent to GOV.UK Pay + my ($token, $report, $report_id) = get_report_from_redirect($sent_create_params->{return_url}); + ok $report, 'report created'; + + # Check report state before payment confirmation + is $report->state, 'unconfirmed', 'report unconfirmed before payment'; + is $report->category, 'Garden Subscription', 'correct category'; + is $report->title, 'Garden Subscription - New', 'correct title'; + is $report->get_extra_metadata('scpReference'), 'govukpay_abc123', + 'scpReference stored in report metadata'; + is $report->get_extra_field_value('payment_method'), 'credit_card', + 'payment method is credit_card'; + is $report->get_extra_field_value('payment'), 5000, 'payment amount on report'; + + # Simulate return from GOV.UK Pay — payment successful + $payment_status = 'success'; + $mech->get_ok("/waste/pay_complete/$report_id/$token"); + + # Check report state after payment confirmation + $report->discard_changes; + is $report->state, 'confirmed', 'report confirmed after successful payment'; + is $report->get_extra_metadata('payment_reference'), 'govukpay_abc123', + 'payment_reference metadata set'; + }; + + subtest 'GOVUKPay: pay_complete with failed payment' => sub { + $mech->get_ok('/waste/12345/garden'); + $mech->submit_form_ok({ form_number => 1 }); + $mech->submit_form_ok({ with_fields => { existing => 'no' } }); + $mech->submit_form_ok({ with_fields => { + current_bins => 0, + bins_wanted => 1, + name => 'Test McTest', + email => 'govukpay-test@example.net', + } }); + $mech->waste_submit_check({ with_fields => { tandc => 1 } }); + + my ($token, $report, $report_id) = get_report_from_redirect($sent_create_params->{return_url}); + ok $report, 'report created for failed payment test'; + + # Simulate return from GOV.UK Pay — payment failed + $payment_status = 'failed'; + $mech->get_ok("/waste/pay_complete/$report_id/$token"); + + $report->discard_changes; + is $report->state, 'unconfirmed', 'report stays unconfirmed on failed payment'; + + # Restore for subsequent tests + $payment_status = 'success'; + }; + + subtest 'GOVUKPay: pay_complete with invalid token returns 404' => sub { + $mech->get_ok('/waste/12345/garden'); + $mech->submit_form_ok({ form_number => 1 }); + $mech->submit_form_ok({ with_fields => { existing => 'no' } }); + $mech->submit_form_ok({ with_fields => { + current_bins => 0, + bins_wanted => 1, + name => 'Test McTest', + email => 'govukpay-test@example.net', + } }); + $mech->waste_submit_check({ with_fields => { tandc => 1 } }); + + my ($token, $report, $report_id) = get_report_from_redirect($sent_create_params->{return_url}); + + $mech->get("/waste/pay_complete/$report_id/WRONG_TOKEN"); + ok !$mech->res->is_success(), 'bad token rejects request'; + is $mech->res->code, 404, 'returns 404 for wrong token'; + }; + + subtest 'GOVUKPay: pay_complete with in-progress payment' => sub { + $mech->get_ok('/waste/12345/garden'); + $mech->submit_form_ok({ form_number => 1 }); + $mech->submit_form_ok({ with_fields => { existing => 'no' } }); + $mech->submit_form_ok({ with_fields => { + current_bins => 0, + bins_wanted => 1, + name => 'Test McTest', + email => 'govukpay-test@example.net', + } }); + $mech->waste_submit_check({ with_fields => { tandc => 1 } }); + + my ($token, $report, $report_id) = get_report_from_redirect($sent_create_params->{return_url}); + + # Simulate return when payment is still in progress + $payment_status = 'submitted'; + $mech->get_ok("/waste/pay_complete/$report_id/$token"); + + $report->discard_changes; + is $report->state, 'unconfirmed', 'report stays unconfirmed while in progress'; + + $payment_status = 'success'; + }; + + subtest 'GOVUKPay: metadata passed to API includes report_id' => sub { + $mech->get_ok('/waste/12345/garden'); + $mech->submit_form_ok({ form_number => 1 }); + $mech->submit_form_ok({ with_fields => { existing => 'no' } }); + $mech->submit_form_ok({ with_fields => { + current_bins => 0, + bins_wanted => 1, + name => 'Test McTest', + email => 'govukpay-test@example.net', + } }); + $mech->waste_submit_check({ with_fields => { tandc => 1 } }); + + ok $sent_create_params->{metadata}, 'metadata sent to GOV.UK Pay'; + ok $sent_create_params->{metadata}{report_id}, 'report_id in metadata'; + is $sent_create_params->{metadata}{category}, 'Garden Subscription', + 'category in metadata'; + }; +}; + +sub get_report_from_redirect { + my $url = shift; + my ($report_id, $token) = ($url =~ m#/(\d+)/([^/]+)$#); + my $new_report = FixMyStreet::DB->resultset('Problem')->find({ + id => $report_id, + }); + return undef unless $new_report + && $new_report->get_extra_metadata('redirect_id') eq $token; + return ($token, $new_report, $report_id); +} + +done_testing; diff --git a/t/integrations/govukpay.t b/t/integrations/govukpay.t new file mode 100644 index 00000000000..91b9c8614f7 --- /dev/null +++ b/t/integrations/govukpay.t @@ -0,0 +1,170 @@ +use strict; +use warnings; + +use Test::More; +use Test::MockModule; +use JSON::MaybeXS; + +use Integrations::GOVUKPay; + +my $json = JSON::MaybeXS->new(utf8 => 1); + +# --- Mock LWP::UserAgent --- + +my %last_request; +my $mock_response_code = 200; +my $mock_response_body = '{}'; + +my $ua_mock = Test::MockModule->new('LWP::UserAgent'); +$ua_mock->mock('request' => sub { + my ($self, $req) = @_; + %last_request = ( + method => $req->method, + url => $req->uri . '', + headers => { map { $_ => $req->header($_) } $req->header_field_names }, + content => $req->content, + ); + + my $resp = HTTP::Response->new($mock_response_code); + $resp->content($mock_response_body); + $resp->content_type('application/json'); + $resp->header('Content-Length' => length $mock_response_body); + return $resp; +}); + +# Suppress syslog calls in tests +my $syslog_mock = Test::MockModule->new('FixMyStreet::Roles::Syslog'); +$syslog_mock->mock('log' => sub {}); + +# --- Set up integration --- + +my $config = { + api_key => 'test_api_key_abc123', + api_url => 'https://publicapi.payments.service.gov.uk', + log_ident => 'test_govukpay', +}; + +my $pay = Integrations::GOVUKPay->new({ config => $config }); + +# --- Tests --- + +subtest 'create_payment sends correct request' => sub { + $mock_response_code = 201; + $mock_response_body = $json->encode({ + payment_id => 'hu20sqlact5260q2nanm0q8u93', + state => { status => 'created', finished => \0 }, + _links => { + next_url => { href => 'https://www.payments.service.gov.uk/secure/abc123' }, + }, + }); + + my $result = $pay->create_payment({ + amount => 2500, + reference => 'ORDER-001', + description => 'Garden waste subscription', + return_url => 'https://example.com/pay_complete/1/token123', + email => 'test@example.com', + metadata => { report_id => '42' }, + }); + + # Check the HTTP request + is $last_request{method}, 'POST', 'uses POST method'; + like $last_request{url}, qr{/v1/payments$}, 'correct URL'; + is $last_request{headers}{'Authorization'}, 'Bearer test_api_key_abc123', 'auth header set'; + + my $sent = $json->decode($last_request{content}); + is $sent->{amount}, 2500, 'amount sent correctly'; + is $sent->{reference}, 'ORDER-001', 'reference sent'; + is $sent->{description}, 'Garden waste subscription', 'description sent'; + is $sent->{return_url}, 'https://example.com/pay_complete/1/token123', 'return_url sent'; + is $sent->{email}, 'test@example.com', 'email sent'; + is $sent->{metadata}{report_id}, '42', 'metadata sent'; + + # Check the result + is $result->{payment_id}, 'hu20sqlact5260q2nanm0q8u93', 'payment_id returned'; + is $result->{next_url}, 'https://www.payments.service.gov.uk/secure/abc123', 'next_url returned'; +}; + +subtest 'create_payment dies on API error' => sub { + $mock_response_code = 400; + $mock_response_body = $json->encode({ + code => 'P0101', + description => 'Missing mandatory attribute: amount', + }); + + eval { $pay->create_payment({ amount => 0, reference => 'X', description => 'X', return_url => 'X' }) }; + like $@, qr/GOV\.UK Pay.*failed.*400/, 'dies with status on error'; +}; + +subtest 'create_payment dies on missing payment_id' => sub { + $mock_response_code = 200; + $mock_response_body = $json->encode({ + _links => { next_url => { href => 'https://example.com' } }, + }); + + eval { $pay->create_payment({ amount => 100, reference => 'X', description => 'X', return_url => 'X' }) }; + like $@, qr/no payment_id/, 'dies when no payment_id in response'; +}; + +subtest 'get_payment_details returns full data' => sub { + $mock_response_code = 200; + $mock_response_body = $json->encode({ + payment_id => 'abc123', + state => { status => 'success', finished => \1 }, + amount => 2500, + reference => 'ORDER-001', + }); + + my $details = $pay->get_payment_details('abc123'); + + like $last_request{url}, qr{/v1/payments/abc123$}, 'correct URL with payment_id'; + is $last_request{method}, 'GET', 'uses GET method'; + is $details->{state}{status}, 'success', 'status returned'; + is $details->{amount}, 2500, 'amount returned'; +}; + +subtest 'get_payment_status returns status string' => sub { + $mock_response_code = 200; + $mock_response_body = $json->encode({ + payment_id => 'abc123', + state => { status => 'submitted', finished => \0 }, + }); + + my $status = $pay->get_payment_status('abc123'); + is $status, 'submitted', 'returns status string'; +}; + +subtest 'get_payment_details dies on 404' => sub { + $mock_response_code = 404; + $mock_response_body = $json->encode({ code => 'P0200', description => 'Not found' }); + + eval { $pay->get_payment_details('nonexistent') }; + like $@, qr/GOV\.UK Pay.*failed.*404/, 'dies on 404'; +}; + +subtest 'search_payments sends query params' => sub { + $mock_response_code = 200; + $mock_response_body = $json->encode({ + total => 1, + count => 1, + results => [{ + payment_id => 'abc123', + state => { status => 'success' }, + }], + }); + + my $result = $pay->search_payments({ reference => 'ORDER-001', state => 'success' }); + + like $last_request{url}, qr{reference=ORDER-001}, 'reference param in URL'; + like $last_request{url}, qr{state=success}, 'state param in URL'; + is $result->{total}, 1, 'total returned'; + is $result->{results}[0]{payment_id}, 'abc123', 'result payment_id returned'; +}; + +subtest 'config defaults' => sub { + my $minimal = Integrations::GOVUKPay->new({ config => { api_key => 'k' } }); + is $minimal->_api_url, 'https://publicapi.payments.service.gov.uk', 'default api_url'; + is $minimal->log_ident, 'govukpay', 'default log_ident'; +}; + +done_testing;