Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions docs/customising/govuk-pay.md
Original file line number Diff line number Diff line change
@@ -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 <https://docs.payments.service.gov.uk/>.

## 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: <https://docs.payments.service.gov.uk/>
226 changes: 226 additions & 0 deletions perllib/FixMyStreet/Roles/Cobrand/GOVUKPay.pm
Original file line number Diff line number Diff line change
@@ -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<FixMyStreet::Roles::Cobrand::SCP> and L<FixMyStreet::Roles::Cobrand::Adelante>.

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<Integrations::GOVUKPay> 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<scpReference> 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<pay_complete>. Fetches the payment
status from GOV.UK Pay, updates the report, and returns the payment
reference on success or C<undef> 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;
Loading