Handle payment notifications from Redsys

I have to basically do the reverse of signing the request to verify that
the notification comes from them.  Lots of code just for that.

I return the changed status from the PL/pgSQL function because i will
need to email customers when a payment is completed, and i need to know
when.
This commit is contained in:
jordi fita mas 2024-02-13 02:38:38 +01:00
parent 15dde3f491
commit ff6750fbea
24 changed files with 827 additions and 22 deletions

View File

@ -0,0 +1,22 @@
-- Deploy camper:decode_base64url to pg
-- requires: roles
-- requires: schema_camper
begin;
set search_path to camper, public;
create or replace function decode_base64url(encoded text) returns bytea as
$$
select decode(translate(encoded, '-_', '+/'), 'base64');
$$
language sql
immutable
;
revoke execute on function decode_base64url(text) from public;
grant execute on function decode_base64url(text) to guest;
grant execute on function decode_base64url(text) to employee;
grant execute on function decode_base64url(text) to admin;
commit;

View File

@ -0,0 +1,30 @@
-- Deploy camper:payment_redsys_response to pg
-- requires: roles
-- requires: schema_camper
-- requires: payment
-- requires: currency_code
begin;
set search_path to camper, public;
create table payment_redsys_response (
payment_id integer primary key references payment,
response_code integer not null,
date_time timestamp without time zone not null,
secure_payment boolean not null,
transaction_type integer not null,
amount integer not null,
currency_code currency_code not null references currency,
order_number text not null,
authorization_code text not null,
merchant_code text not null,
terminal_number integer not null,
error_code text not null
);
grant select, insert, update on table payment_redsys_response to guest;
grant select, insert, update on table payment_redsys_response to employee;
grant select, insert, update, delete on table payment_redsys_response to admin;
commit;

View File

@ -0,0 +1,89 @@
-- Deploy camper:process_payment_response to pg
-- requires: roles
-- requires: schema_camper
-- requires: redsys_response
-- requires: payment
-- requires: payment_redsys_response
-- requires: parse_price
-- requires: currency
begin;
set search_path to camper, public;
create or replace function process_payment_response(payment_slug uuid, response redsys_response) returns text as
$$
declare
pid integer;
next_status text;
begin
if response.transaction_type <> 0 then
raise invalid_parameter_value using message = response.transaction_type || ' is not a processable transaction type';
end if;
update payment
set payment_status = case when response.response_code < 100 then 'completed' else 'failed' end
, updated_at = current_timestamp
where slug = payment_slug
and payment_status in ('pending', 'failed')
returning payment_id, payment_status
into pid, next_status;
if pid is null then
return '';
end if;
insert into payment_redsys_response (
payment_id
, response_code
, date_time
, secure_payment
, transaction_type
, amount
, currency_code
, order_number
, authorization_code
, merchant_code
, terminal_number
, error_code
)
select pid
, response.response_code
, response.date_time
, response.secure_payment
, response.transaction_type
, parse_price(response.amount, decimal_digits)
, response.currency_code
, response.order_number
, response.authorization_code
, response.merchant_code
, response.terminal_number
, response.error_code
from currency
where currency.currency_code = response.currency_code
on conflict (payment_id) do update
set response_code = excluded.response_code
, date_time = excluded.date_time
, secure_payment = excluded.secure_payment
, transaction_type = excluded.transaction_type
, amount = excluded.amount
, currency_code = excluded.currency_code
, order_number = excluded.order_number
, authorization_code = excluded.authorization_code
, merchant_code = excluded.merchant_code
, terminal_number = excluded.terminal_number
, error_code = excluded.error_code
;
return next_status;
end;
$$
language plpgsql
;
revoke execute on function process_payment_response(uuid, redsys_response) from public;
grant execute on function process_payment_response(uuid, redsys_response) to guest;
grant execute on function process_payment_response(uuid, redsys_response) to employee;
grant execute on function process_payment_response(uuid, redsys_response) to admin;
commit;

View File

@ -0,0 +1,50 @@
-- Deploy camper:redsys_decode_response to pg
-- requires: roles
-- requires: schema_camper
-- requires: extension_pgcrypto
-- requires: decode_base64url
-- requires: redsys_encrypt
-- requires: redsys_response
-- requires: company
-- requires: currency
-- requires: to_price
begin;
set search_path to camper, public;
create or replace function redsys_decode_response(company_id integer, encoded_data text, encoded_signature text, signature_version text) returns redsys_response as
$$
select row (
obj ->> 'Ds_MerchantCode'
, to_number((obj ->> 'Ds_Terminal'), '999')::integer
, to_number((obj ->> 'Ds_Response'), '9999')::integer
, to_timestamp((obj ->> 'Ds_Date') || ' ' || (obj ->> 'Ds_Hour'), 'DD/MM/YYYY HH24:MI')
, obj ->> 'Ds_SecurePayment' = '1'
, (obj ->> 'Ds_TransactionType')::integer
, to_price((obj ->> 'Ds_Amount')::integer, decimal_digits)
, currency_code
, obj ->> 'Ds_Order'
, coalesce(obj ->> 'Ds_AuthorisationCode', '')
, coalesce(obj ->> 'Ds_ErrorCode', '')
)::redsys_response
from (
select convert_to(encoded_data, 'UTF-8') as raw
, convert_from(decode_base64url(encoded_data), 'UTF-8')::jsonb as obj
, decode_base64url(encoded_signature) as signature
, signature_version
) as response
join currency on currency.redsys_code = (obj ->> 'Ds_Currency')::integer
where signature = hmac(raw, redsys_encrypt(company_id, convert_to(obj ->> 'Ds_Order', 'UTF-8')), 'sha256')
and signature_version = 'HMAC_SHA256_V1';
$$
language sql
stable
;
revoke execute on function redsys_decode_response(integer, text, text, text) from public;
grant execute on function redsys_decode_response(integer, text, text, text) to guest;
grant execute on function redsys_decode_response(integer, text, text, text) to employee;
grant execute on function redsys_decode_response(integer, text, text, text) to admin;
commit;

View File

@ -0,0 +1,23 @@
-- Deploy camper:redsys_response to pg
-- requires: schema_camper
-- requires: currency_code
begin;
set search_path to camper, public;
create type redsys_response as (
merchant_code text
, terminal_number integer
, response_code integer
, date_time timestamp
, secure_payment boolean
, transaction_type integer
, amount text
, currency_code currency_code
, order_number text
, authorization_code text
, error_code text
);
commit;

View File

@ -13,9 +13,10 @@ import (
) )
const ( const (
RedsysRequestTypeName = "redsys_request"
RedsysSignedRequestTypeName = "redsys_signed_request"
OptionUnitsTypeName = "option_units" OptionUnitsTypeName = "option_units"
RedsysRequestTypeName = "redsys_request"
RedsysResponseTypeName = "redsys_response"
RedsysSignedRequestTypeName = "redsys_signed_request"
) )
var ( var (
@ -69,6 +70,30 @@ func registerConnectionTypes(ctx context.Context, conn *pgx.Conn) error {
return err return err
} }
redsysResponseType, err := pgtype.NewCompositeType(
RedsysResponseTypeName,
[]pgtype.CompositeTypeField{
{"merchant_code", pgtype.TextOID},
{"terminal_number", pgtype.Int4OID},
{"response_code", pgtype.Int4OID},
{"date_time", pgtype.TimestampOID},
{"secure_payment", pgtype.BoolOID},
{"transaction_type", pgtype.Int4OID},
{"amount", pgtype.TextOID},
{"currency_code", pgtype.TextOID},
{"order_number", pgtype.TextOID},
{"authorization_code", pgtype.TextOID},
{"error_code", pgtype.TextOID},
},
conn.ConnInfo(),
)
if err != nil {
return err
}
if _, err = registerType(ctx, conn, redsysResponseType, redsysResponseType.TypeName()); err != nil {
return err
}
redsysSignedRequestType, err := pgtype.NewCompositeType( redsysSignedRequestType, err := pgtype.NewCompositeType(
RedsysSignedRequestTypeName, RedsysSignedRequestTypeName,
[]pgtype.CompositeTypeField{ []pgtype.CompositeTypeField{

View File

@ -14,6 +14,14 @@ import (
"dev.tandem.ws/tandem/camper/pkg/uuid" "dev.tandem.ws/tandem/camper/pkg/uuid"
) )
const (
StatusDraft = "draft"
StatusPending = "pending"
StatusFailed = "failed"
StatusCompleted = "completed"
StatusRefunded = "refunded"
)
type PublicHandler struct { type PublicHandler struct {
} }
@ -54,6 +62,8 @@ func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *da
handleSuccessfulPayment(w, r, user, company, conn, payment) handleSuccessfulPayment(w, r, user, company, conn, payment)
case "failure": case "failure":
handleFailedPayment(w, r, user, company, conn, payment) handleFailedPayment(w, r, user, company, conn, payment)
case "notification":
handleNotification(w, r, user, company, conn, payment)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -90,7 +100,7 @@ func (payment *Payment) createRequest(r *http.Request, user *auth.User, company
schema := httplib.Protocol(r) schema := httplib.Protocol(r)
authority := httplib.Host(r) authority := httplib.Host(r)
baseURL := fmt.Sprintf("%s://%s/%s/payments/%s", schema, authority, user.Locale.Language, payment.Slug) baseURL := fmt.Sprintf("%s://%s/%s/payments/%s", schema, authority, user.Locale.Language, payment.Slug)
request := &redsys.Request{ request := redsys.Request{
TransactionType: redsys.TransactionTypeCharge, TransactionType: redsys.TransactionTypeCharge,
Amount: payment.Total, Amount: payment.Total,
OrderNumber: payment.OrderNumber(), OrderNumber: payment.OrderNumber(),
@ -100,7 +110,7 @@ func (payment *Payment) createRequest(r *http.Request, user *auth.User, company
NotificationURL: fmt.Sprintf("%s/notification", baseURL), NotificationURL: fmt.Sprintf("%s/notification", baseURL),
ConsumerLanguage: user.Locale.Language, ConsumerLanguage: user.Locale.Language,
} }
return redsys.SignRequest(r.Context(), conn, company, request) return request.Sign(r.Context(), conn, company)
} }
func (payment *Payment) OrderNumber() string { func (payment *Payment) OrderNumber() string {
@ -202,3 +212,50 @@ func (p *failedPaymentPage) MustRender(w http.ResponseWriter, r *http.Request, u
p.Setup(r, user, company, conn) p.Setup(r, user, company, conn)
template.MustRenderPublicFiles(w, r, user, company, p, "payment/failure.gohtml", "payment/details.gohtml") template.MustRenderPublicFiles(w, r, user, company, p, "payment/failure.gohtml", "payment/details.gohtml")
} }
func handleNotification(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, payment *Payment) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodPost:
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
signed := redsys.SignedResponse{
MerchantParameters: r.Form.Get("Ds_MerchantParameters"),
Signature: r.Form.Get("Ds_Signature"),
SignatureVersion: r.Form.Get("Ds_SignatureVersion"),
}
response, err := signed.Decode(r.Context(), conn, company)
if err != nil {
panic(err)
}
if response == nil {
http.Error(w, "Invalid response", http.StatusBadRequest)
return
}
if response.OrderNumber != payment.OrderNumber() {
http.Error(w, "Response for a different payment", http.StatusBadRequest)
return
}
status, err := response.Process(r.Context(), conn, payment.Slug)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
switch status {
case StatusCompleted:
default:
}
w.WriteHeader(http.StatusNoContent)
default:
httplib.MethodNotAllowed(w, r, http.MethodPost)
}
default:
http.NotFound(w, r)
}
}

View File

@ -8,15 +8,16 @@ package redsys
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/jackc/pgtype" "github.com/jackc/pgtype"
"golang.org/x/text/language" "golang.org/x/text/language"
"time"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/database"
) )
type TransactionType int type TransactionType int
type ResponseCode int
const ( const (
TransactionTypeCharge TransactionType = 0 TransactionTypeCharge TransactionType = 0
@ -26,6 +27,16 @@ const (
TransactionTypePreauthVoid TransactionType = 9 TransactionTypePreauthVoid TransactionType = 9
TransactionTypeSplitAuth TransactionType = 7 TransactionTypeSplitAuth TransactionType = 7
TransactionTypeSplitConfirm TransactionType = 8 TransactionTypeSplitConfirm TransactionType = 8
ResponsePaymentAuthorized ResponseCode = 99
ResponseRefundAuthorized ResponseCode = 900
ResponseCancelAuthorized ResponseCode = 400
ResponseCardExpired ResponseCode = 101
ResponseCardException ResponseCode = 102
ResponseTooManyPINAttempts ResponseCode = 106
ResponseCardNotEffective ResponseCode = 125
ResponseIncorrectSecureCode ResponseCode = 129
ResponseDenied ResponseCode = 172
) )
type Request struct { type Request struct {
@ -40,13 +51,7 @@ type Request struct {
ConsumerLanguage language.Tag ConsumerLanguage language.Tag
} }
type SignedRequest struct { func (req Request) Sign(ctx context.Context, conn *database.Conn, company *auth.Company) (*SignedRequest, error) {
MerchantParameters string
Signature string
SignatureVersion string
}
func SignRequest(ctx context.Context, conn *database.Conn, company *auth.Company, req *Request) (*SignedRequest, error) {
row := conn.QueryRow(ctx, "select redsys_sign_request($1, $2)", company.ID, req) row := conn.QueryRow(ctx, "select redsys_sign_request($1, $2)", company.ID, req)
signed := &SignedRequest{} signed := &SignedRequest{}
if err := row.Scan(&signed); err != nil { if err := row.Scan(&signed); err != nil {
@ -55,22 +60,22 @@ func SignRequest(ctx context.Context, conn *database.Conn, company *auth.Company
return signed, nil return signed, nil
} }
func (src Request) EncodeText(ci *pgtype.ConnInfo, dst []byte) ([]byte, error) { func (req Request) EncodeText(ci *pgtype.ConnInfo, dst []byte) ([]byte, error) {
typeName := database.RedsysRequestTypeName typeName := database.RedsysRequestTypeName
dt, ok := ci.DataTypeForName(typeName) dt, ok := ci.DataTypeForName(typeName)
if !ok { if !ok {
return nil, fmt.Errorf("unable to find oid for type name %v", typeName) return nil, fmt.Errorf("unable to find oid for type name %v", typeName)
} }
values := []interface{}{ values := []interface{}{
src.TransactionType, req.TransactionType,
src.Amount, req.Amount,
src.OrderNumber, req.OrderNumber,
src.Product, req.Product,
src.CardHolder, req.CardHolder,
src.SuccessURL, req.SuccessURL,
src.FailureURL, req.FailureURL,
src.NotificationURL, req.NotificationURL,
src.ConsumerLanguage, req.ConsumerLanguage,
} }
ct := pgtype.NewValue(dt.Value).(*pgtype.CompositeType) ct := pgtype.NewValue(dt.Value).(*pgtype.CompositeType)
if err := ct.Set(values); err != nil { if err := ct.Set(values); err != nil {
@ -79,6 +84,12 @@ func (src Request) EncodeText(ci *pgtype.ConnInfo, dst []byte) ([]byte, error) {
return ct.EncodeText(ci, dst) return ct.EncodeText(ci, dst)
} }
type SignedRequest struct {
MerchantParameters string
Signature string
SignatureVersion string
}
func (dst *SignedRequest) DecodeText(ci *pgtype.ConnInfo, src []byte) error { func (dst *SignedRequest) DecodeText(ci *pgtype.ConnInfo, src []byte) error {
typeName := database.RedsysSignedRequestTypeName typeName := database.RedsysSignedRequestTypeName
dt, ok := ci.DataTypeForName(typeName) dt, ok := ci.DataTypeForName(typeName)
@ -91,3 +102,62 @@ func (dst *SignedRequest) DecodeText(ci *pgtype.ConnInfo, src []byte) error {
} }
return ct.AssignTo(dst) return ct.AssignTo(dst)
} }
type SignedResponse struct {
MerchantParameters string
Signature string
SignatureVersion string
}
func (signed SignedResponse) Decode(ctx context.Context, conn *database.Conn, company *auth.Company) (*Response, error) {
row := conn.QueryRow(ctx, "select redsys_decode_response($1, $2, $3, $4)", company.ID, signed.MerchantParameters, signed.Signature, signed.SignatureVersion)
response := &Response{}
if err := row.Scan(&response); err != nil {
return nil, err
}
return response, nil
}
type Response struct {
MerchantCode string
TerminalNumber int
ResponseCode ResponseCode
DateTime time.Time
SecurePayment bool
TransactionType TransactionType
Amount string
CurrencyCode string
OrderNumber string
AuthorizationCode string
ErrorCode string
}
func (response *Response) Process(ctx context.Context, conn *database.Conn, paymentSlug string) (string, error) {
return conn.GetText(ctx, "select process_payment_response($1, $2)", paymentSlug, response)
}
func (response *Response) EncodeBinary(ci *pgtype.ConnInfo, dst []byte) ([]byte, error) {
typeName := database.RedsysResponseTypeName
dt, ok := ci.DataTypeForName(typeName)
if !ok {
return nil, fmt.Errorf("unable to find oid for type name %v", typeName)
}
values := []interface{}{
response.MerchantCode,
response.TerminalNumber,
response.ResponseCode,
response.DateTime,
response.SecurePayment,
response.TransactionType,
response.Amount,
response.CurrencyCode,
response.OrderNumber,
response.AuthorizationCode,
response.ErrorCode,
}
ct := pgtype.NewValue(dt.Value).(*pgtype.CompositeType)
if err := ct.Set(values); err != nil {
return nil, err
}
return ct.EncodeBinary(ci, dst)
}

View File

@ -0,0 +1,7 @@
-- Revert camper:decode_base64url from pg
begin;
drop function if exists camper.decode_base64url(text);
commit;

View File

@ -0,0 +1,7 @@
-- Revert camper:payment_redsys_response from pg
begin;
drop table if exists camper.payment_redsys_response;
commit;

View File

@ -0,0 +1,7 @@
-- Revert camper:process_payment_response from pg
begin;
drop function if exists camper.process_payment_response(uuid, camper.redsys_response);
commit;

View File

@ -0,0 +1,7 @@
-- Revert camper:redsys_decode_response from pg
begin;
drop function if exists camper.redsys_decode_response(integer, text, text, text);
commit;

View File

@ -0,0 +1,7 @@
-- Revert camper:redsys_response from pg
begin;
drop type if exists camper.redsys_response;
commit;

View File

@ -248,3 +248,8 @@ payment_customer [roles schema_camper payment country country_code extension_pg_
payment_option [roles schema_camper payment campsite_type_option] 2024-02-12T00:58:07Z jordi fita mas <jordi@tandem.blog> # Add relation of payment for campsite type options payment_option [roles schema_camper payment campsite_type_option] 2024-02-12T00:58:07Z jordi fita mas <jordi@tandem.blog> # Add relation of payment for campsite type options
draft_payment [roles schema_camper season_calendar season campsite_type campsite_type_pet_cost campsite_type_cost campsite_type_option_cost campsite_type_option payment payment_option] 2024-02-12T01:31:52Z jordi fita mas <jordi@tandem.blog> # Add function to create a payment draft draft_payment [roles schema_camper season_calendar season campsite_type campsite_type_pet_cost campsite_type_cost campsite_type_option_cost campsite_type_option payment payment_option] 2024-02-12T01:31:52Z jordi fita mas <jordi@tandem.blog> # Add function to create a payment draft
ready_payment [roles schema_camper payment payment_customer country_code email extension_pg_libphonenumber] 2024-02-12T12:57:24Z jordi fita mas <jordi@tandem.blog> # Add function to ready a draft payment ready_payment [roles schema_camper payment payment_customer country_code email extension_pg_libphonenumber] 2024-02-12T12:57:24Z jordi fita mas <jordi@tandem.blog> # Add function to ready a draft payment
redsys_response [schema_camper currency_code] 2024-02-12T19:49:29Z jordi fita mas <jordi@tandem.blog> # Add type for Redsys responses
decode_base64url [roles schema_camper] 2024-02-12T20:03:17Z jordi fita mas <jordi@tandem.blog> # Add function to decode the so-called base64url to bytea
redsys_decode_response [roles schema_camper extension_pgcrypto decode_base64url redsys_encrypt redsys_response company currency to_price] 2024-02-12T20:52:09Z jordi fita mas <jordi@tandem.blog> # Add function to decode a Redsys signed response
payment_redsys_response [roles schema_camper payment currency_code] 2024-02-12T21:32:23Z jordi fita mas <jordi@tandem.blog> # Add relation for Redsys responses to payments
process_payment_response [roles schema_camper redsys_response payment payment_redsys_response parse_price currency] 2024-02-12T22:04:48Z jordi fita mas <jordi@tandem.blog> # Add function to process Redsys response of a payment

42
test/decode_base64url.sql Normal file
View File

@ -0,0 +1,42 @@
-- Test decode_base64url
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(13);
set search_path to camper, public;
select has_function('camper', 'decode_base64url', array['text']);
select function_lang_is('camper', 'decode_base64url', array['text'], 'sql');
select function_returns('camper', 'decode_base64url', array['text'], 'bytea');
select isnt_definer('camper', 'decode_base64url', array['text']);
select volatility_is('camper', 'decode_base64url', array['text'], 'immutable');
select function_privs_are('camper', 'decode_base64url', array ['text'], 'guest', array['EXECUTE']);
select function_privs_are('camper', 'decode_base64url', array ['text'], 'employee', array['EXECUTE']);
select function_privs_are('camper', 'decode_base64url', array ['text'], 'admin', array['EXECUTE']);
select function_privs_are('camper', 'decode_base64url', array ['text'], 'authenticator', array[]::text[]);
select is(
decode_base64url('sq7HjrUOBfKmC576ILgskD5srU870gJ7'),
decode('sq7HjrUOBfKmC576ILgskD5srU870gJ7', 'base64')
);
select is(
decode_base64url('K-gWWTl-gKlkxEGDsuc0UunIQwoFLM0t'),
decode('K+gWWTl+gKlkxEGDsuc0UunIQwoFLM0t', 'base64')
);
select is(
decode_base64url('cQGdqnb4V_nSKe8zTzyP_VaDGd2rAmzt'),
decode('cQGdqnb4V/nSKe8zTzyP/VaDGd2rAmzt', 'base64')
);
select is(
decode_base64url('x59e-kbXSpgsmy31V5l2BtTuKv8_pVKg'),
decode('x59e+kbXSpgsmy31V5l2BtTuKv8/pVKg', 'base64')
);
select *
from finish();
rollback;

View File

@ -0,0 +1,89 @@
-- Test payment_redsys_response
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(59);
set search_path to camper, public;
select has_table('payment_redsys_response');
select has_pk('payment_redsys_response');
select table_privs_are('payment_redsys_response', 'guest', array['SELECT', 'INSERT', 'UPDATE']);
select table_privs_are('payment_redsys_response', 'employee', array['SELECT', 'INSERT', 'UPDATE']);
select table_privs_are('payment_redsys_response', 'admin', array['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
select table_privs_are('payment_redsys_response', 'authenticator', array[]::text[]);
select has_column('payment_redsys_response', 'payment_id');
select col_is_pk('payment_redsys_response', 'payment_id');
select col_is_fk('payment_redsys_response', 'payment_id');
select fk_ok('payment_redsys_response', 'payment_id', 'payment', 'payment_id');
select col_type_is('payment_redsys_response', 'payment_id', 'integer');
select col_not_null('payment_redsys_response', 'payment_id');
select col_hasnt_default('payment_redsys_response', 'payment_id');
select has_column('payment_redsys_response', 'response_code');
select col_type_is('payment_redsys_response', 'response_code', 'integer');
select col_not_null('payment_redsys_response', 'response_code');
select col_hasnt_default('payment_redsys_response', 'response_code');
select has_column('payment_redsys_response', 'date_time');
select col_type_is('payment_redsys_response', 'date_time', 'timestamp without time zone');
select col_not_null('payment_redsys_response', 'date_time');
select col_hasnt_default('payment_redsys_response', 'date_time');
select has_column('payment_redsys_response', 'secure_payment');
select col_type_is('payment_redsys_response', 'secure_payment', 'boolean');
select col_not_null('payment_redsys_response', 'secure_payment');
select col_hasnt_default('payment_redsys_response', 'secure_payment');
select has_column('payment_redsys_response', 'transaction_type');
select col_type_is('payment_redsys_response', 'transaction_type', 'integer');
select col_not_null('payment_redsys_response', 'transaction_type');
select col_hasnt_default('payment_redsys_response', 'transaction_type');
select has_column('payment_redsys_response', 'amount');
select col_type_is('payment_redsys_response', 'amount', 'integer');
select col_not_null('payment_redsys_response', 'amount');
select col_hasnt_default('payment_redsys_response', 'amount');
select has_column('payment_redsys_response', 'currency_code');
select col_is_fk('payment_redsys_response', 'currency_code');
select fk_ok('payment_redsys_response', 'currency_code', 'currency', 'currency_code');
select col_type_is('payment_redsys_response', 'currency_code', 'currency_code');
select col_not_null('payment_redsys_response', 'currency_code');
select col_hasnt_default('payment_redsys_response', 'currency_code');
select has_column('payment_redsys_response', 'order_number');
select col_type_is('payment_redsys_response', 'order_number', 'text');
select col_not_null('payment_redsys_response', 'order_number');
select col_hasnt_default('payment_redsys_response', 'order_number');
select has_column('payment_redsys_response', 'authorization_code');
select col_type_is('payment_redsys_response', 'authorization_code', 'text');
select col_not_null('payment_redsys_response', 'authorization_code');
select col_hasnt_default('payment_redsys_response', 'authorization_code');
select has_column('payment_redsys_response', 'merchant_code');
select col_type_is('payment_redsys_response', 'merchant_code', 'text');
select col_not_null('payment_redsys_response', 'merchant_code');
select col_hasnt_default('payment_redsys_response', 'merchant_code');
select has_column('payment_redsys_response', 'terminal_number');
select col_type_is('payment_redsys_response', 'terminal_number', 'integer');
select col_not_null('payment_redsys_response', 'terminal_number');
select col_hasnt_default('payment_redsys_response', 'terminal_number');
select has_column('payment_redsys_response', 'error_code');
select col_type_is('payment_redsys_response', 'error_code', 'text');
select col_not_null('payment_redsys_response', 'error_code');
select col_hasnt_default('payment_redsys_response', 'error_code');
select *
from finish();
rollback;

View File

@ -0,0 +1,135 @@
-- Test process_payment_response
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(19);
set search_path to camper, public;
select has_function('camper', 'process_payment_response', array['uuid', 'redsys_response']);
select function_lang_is('camper', 'process_payment_response', array['uuid', 'redsys_response'], 'plpgsql');
select function_returns('camper', 'process_payment_response', array['uuid', 'redsys_response'], 'text');
select isnt_definer('camper', 'process_payment_response', array['uuid', 'redsys_response']);
select volatility_is('camper', 'process_payment_response', array['uuid', 'redsys_response'], 'volatile');
select function_privs_are('camper', 'process_payment_response', array ['uuid', 'redsys_response'], 'guest', array['EXECUTE']);
select function_privs_are('camper', 'process_payment_response', array ['uuid', 'redsys_response'], 'employee', array['EXECUTE']);
select function_privs_are('camper', 'process_payment_response', array ['uuid', 'redsys_response'], 'admin', array['EXECUTE']);
select function_privs_are('camper', 'process_payment_response', array ['uuid', 'redsys_response'], 'authenticator', array[]::text[]);
set client_min_messages to warning;
truncate payment_redsys_response cascade;
truncate payment cascade;
truncate campsite_type cascade;
truncate media cascade;
truncate media_content cascade;
truncate company cascade;
reset client_min_messages;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, country_code, currency_code, default_lang_tag)
values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 350, 'ES', 'EUR', 'ca')
;
insert into media_content (media_type, bytes)
values ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (10, 2, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
;
insert into campsite_type (campsite_type_id, slug, company_id, name, media_id, max_campers, bookable_nights, overflow_allowed)
values (12, 'c1b6f4fc-32c1-4cd5-b796-0c5059152a52', 2, 'Plots', 10, 6, '[1, 7]', true)
;
insert into payment (payment_id, slug, company_id, campsite_type_id, arrival_date, departure_date, subtotal_nights, number_adults, subtotal_adults, number_teenagers, subtotal_teenagers, number_children, subtotal_children, number_dogs, subtotal_dogs, subtotal_tourist_tax, total, zone_preferences, payment_status, created_at, updated_at)
values (22, '4ef35e2f-ef98-42d6-a724-913bd761ca8c', 2, 12, '2024-08-28', '2024-09-04', 3200, 2, 10420, 4, 20840, 6, 25080, 3, 2450, 4900, 79160, 'pref I before E', 'draft', '2024-01-01 01:01:01', '2024-01-01 01:01:01')
, (24, '6d1b8e4c-c3c6-4fe4-92c1-2cbf94526693', 2, 12, '2024-08-29', '2024-09-03', 71000, 1, 0, 2, 0, 3, 0, 0, 0, 1750, 72750, '', 'pending', '2024-01-02 02:02:02', '2024-01-02 02:02:02')
, (26, '8d38a482-8a25-4d85-9929-e5f425fcac04', 2, 12, '2024-08-29', '2024-09-03', 71000, 1, 0, 2, 0, 3, 0, 0, 0, 1750, 72750, '', 'completed', '2024-01-03 03:03:03', '2024-01-03 03:03:03')
, (28, 'b770f8b7-f148-4ab4-a786-aa070af598e5', 2, 12, '2024-08-29', '2024-09-03', 71000, 1, 0, 2, 0, 3, 0, 0, 0, 1750, 72750, '', 'failed', '2024-01-04 04:04:04', '2024-01-04 04:04:04')
, (30, '31910d73-d343-44b7-8a29-f7e075b64933', 2, 12, '2024-08-29', '2024-09-03', 71000, 1, 0, 2, 0, 3, 0, 0, 0, 1750, 72750, '', 'refunded', '2024-01-05 05:05:05', '2024-01-05 05:05:05')
, (32, 'c9488490-ac09-4402-90cd-f6f0546f04c0', 2, 12, '2024-08-29', '2024-09-03', 71000, 1, 0, 2, 0, 3, 0, 0, 0, 1750, 72750, '', 'pending', '2024-01-05 05:05:05', '2024-01-05 05:05:05')
, (34, '5819823e-c0ac-4baa-a3ae-515fbb70e909', 2, 12, '2024-08-29', '2024-09-03', 71000, 1, 0, 2, 0, 3, 0, 0, 0, 1750, 72750, '', 'pending', '2024-01-05 05:05:05', '2024-01-06 06:06:06')
;
insert into payment_redsys_response (payment_id, response_code, date_time, secure_payment, transaction_type, amount, currency_code, order_number, authorization_code, merchant_code, terminal_number, error_code)
values (28, 0, '2023-01-01 01:01:01', false, 1, 1000, 'EUR', 'huh?', '123', '1234567', 5, '123')
;
select is(
process_payment_response('4ef35e2f-ef98-42d6-a724-913bd761ca8c', row('3322445', 2, 0, '2024-02-02 12:23:34', true, 0, '12.35', 'EUR', '000000224ef3', '124', '')::redsys_response),
'',
'Should not change a draft payment'
);
select is(
process_payment_response('6d1b8e4c-c3c6-4fe4-92c1-2cbf94526693', row('3322446', 2, 100, '2024-02-03 12:23:34', false, 0, '12.36', 'USD', '000000246d1b', '125', 'ERR')::redsys_response),
'failed',
'Should fail a pending payment if response code > 99'
);
select is(
process_payment_response('8d38a482-8a25-4d85-9929-e5f425fcac04', row('3322447', 3, 0, '2024-02-04 12:23:34', true, 0, '12.37', 'EUR', '000000268d3a', '126', '')::redsys_response),
'',
'Should not change a completed payment'
);
select is(
process_payment_response('b770f8b7-f148-4ab4-a786-aa070af598e5', row('3322448', 4, 99, '2024-02-05 12:23:34', true, 0, '12.38', 'EUR', '00000028b770', '127', '')::redsys_response),
'completed',
'Should change a failed payment if response code < 100'
);
select is(
process_payment_response('31910d73-d343-44b7-8a29-f7e075b64933', row('3322449', 5, 0, '2024-02-06 12:23:34', false, 0, '12.39', 'EUR', '000000303190', '128', '')::redsys_response),
'',
'Should not change a refunded payment'
);
select is(
process_payment_response('c9488490-ac09-4402-90cd-f6f0546f04c0', row('3322450', 6, 0, '2024-02-07 12:23:34', true, 0, '12.40', 'EUR', '00000032c948', '129', 'NOPE')::redsys_response),
'completed',
'Should change a pending payment if response code < 100'
);
select is(
process_payment_response('c9488490-ac09-4402-90cd-f6f0546f04c0', row('3322450', 6, 0, '2024-02-07 12:23:34', true, 0, '12.40', 'EUR', '00000032c948', '129', '')::redsys_response),
'',
'Should NOT change a payment twice'
);
select throws_ok(
$$ select process_payment_response('5819823e-c0ac-4baa-a3ae-515fbb70e909', row('3322445', 2, 0, '2024-02-02 12:23:34', false, 3, '12.41', 'USD', '000000345819', '130', '')::redsys_response) $$,
'22023', '3 is not a processable transaction type',
'Only transaction type = 0 are allowed for now'
);
select bag_eq(
$$ select payment_id, payment_status, updated_at from payment $$,
$$ values (22, 'draft', '2024-01-01 01:01:01')
, (24, 'failed', current_timestamp)
, (26, 'completed', '2024-01-03 03:03:03')
, (28, 'completed', current_timestamp)
, (30, 'refunded', '2024-01-05 05:05:05')
, (32, 'completed', current_timestamp)
, (34, 'pending', '2024-01-06 06:06:06')
$$,
'Should have updated payments'
);
select bag_eq(
$$ select payment_id, merchant_code, terminal_number, response_code, date_time::text, secure_payment, transaction_type, amount, currency_code, order_number, authorization_code, error_code from payment_redsys_response $$,
$$ values (24, '3322446', 2, 100, '2024-02-03 12:23:34', false, 0, 1236, 'USD', '000000246d1b', '125', 'ERR')
, (28, '3322448', 4, 99, '2024-02-05 12:23:34', true, 0, 1238, 'EUR', '00000028b770', '127', '')
, (32, '3322450', 6, 0, '2024-02-07 12:23:34', true, 0, 1240, 'EUR', '00000032c948', '129', 'NOPE')
$$,
'Should have added responses'
);
select *
from finish();
rollback;

View File

@ -0,0 +1,56 @@
-- Test redsys_decode_response
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(12);
set search_path to camper, public;
select has_function('camper', 'redsys_decode_response', array['integer', 'text', 'text', 'text']);
select function_lang_is('camper', 'redsys_decode_response', array['integer', 'text', 'text', 'text'], 'sql');
select function_returns('camper', 'redsys_decode_response', array['integer', 'text', 'text', 'text'], 'redsys_response');
select isnt_definer('camper', 'redsys_decode_response', array['integer', 'text', 'text', 'text']);
select volatility_is('camper', 'redsys_decode_response', array['integer', 'text', 'text', 'text'], 'stable');
select function_privs_are('camper', 'redsys_decode_response', array ['integer', 'text', 'text', 'text'], 'guest', array['EXECUTE']);
select function_privs_are('camper', 'redsys_decode_response', array ['integer', 'text', 'text', 'text'], 'employee', array['EXECUTE']);
select function_privs_are('camper', 'redsys_decode_response', array ['integer', 'text', 'text', 'text'], 'admin', array['EXECUTE']);
select function_privs_are('camper', 'redsys_decode_response', array ['integer', 'text', 'text', 'text'], 'authenticator', array[]::text[]);
set client_min_messages to warning;
truncate redsys cascade;
truncate company cascade;
reset client_min_messages;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, country_code, currency_code, default_lang_tag)
values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 'ES', 'EUR', 'ca')
, (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', '', 60, 'FR', 'USD', 'ca')
;
insert into redsys (company_id, merchant_code, terminal_number, environment, integration, encrypt_key)
values (2, '222222222', 2, 'live', 'insite', decode('x8RS+UkDkAN3i5hGU92U2c5BhJlffg8w', 'base64'))
, (4, '999008881', 4, 'test', 'insite', decode('sq7HjrUOBfKmC576ILgskD5srU870gJ7', 'base64'))
;
select is(
redsys_decode_response(4, 'eyJEc19NZXJjaGFudENvZGUiOiIzNjE3MTY5NjIiLCJEc19UZXJtaW5hbCI6IjAwMSIsIkRzX09yZGVyIjoiNjk5ZmQ2YWRjYTE4IiwiRHNfQW1vdW50IjoiMTAwMCIsIkRzX0N1cnJlbmN5IjoiOTc4IiwiRHNfRGF0ZSI6IjEyXC8wMlwvMjAyNCIsIkRzX0hvdXIiOiIxODowMCIsIkRzX1NlY3VyZVBheW1lbnQiOiIxIiwiRHNfQ2FyZF9Db3VudHJ5IjoiNzI0IiwiRHNfUmVzcG9uc2UiOiIwMDAwIiwiRHNfTWVyY2hhbnREYXRhIjoiIiwiRHNfVHJhbnNhY3Rpb25UeXBlIjoiMCIsIkRzX0NvbnN1bWVyTGFuZ3VhZ2UiOiIyIiwiRHNfQXV0aG9yaXNhdGlvbkNvZGUiOiIyOTM3MzYiLCJEc19DYXJkX0JyYW5kIjoiMSIsIkRzX1Byb2Nlc3NlZFBheU1ldGhvZCI6IjgwIn0=', 'uOR3HLlSMH3zrHINAoDv1-MK9tSj5Vo4Og9oAh6Uj08=', 'HMAC_SHA256_V1'),
row('361716962', 1, 0, '2024-02-12 18:00:00.000000 +00:00'::timestamp, true, 0, '10.00', 'EUR', '699fd6adca18', '293736', '')::redsys_response
);
select is(
redsys_decode_response(4, 'eyJEc19NZXJjaGFudF9NZXJjaGFudENvZGUiIDogIjk5OTAwODg4MSIsICJEc19NZXJjaGFudF9UZXJtaW5hbCIgOiAiNCIsICJEc19NZXJjaGFudF9NZXJjaGFudE5hbWUiIDogIkNvbXBhbnkgNCIsICJEc19NZXJjaGFudF9UcmFuc2FjdGlvblR5cGUiIDogIjEiLCAiRHNfTWVyY2hhbnRfQW1vdW50IiA6ICIyMTQ0MDAiLCAiRHNfTWVyY2hhbnRfQ3VycmVuY3kiIDogIjg0MCIsICJEc19NZXJjaGFudF9PcmRlciIgOiAiMDAwMEFCQ0RFIiwgIkRzX01lcmNoYW50X1Byb2R1Y3RkZXNjcmlwdGlvbiIgOiAiQm9va2luZyBBQkNERSIsICJEc19NZXJjaGFudF9UaXR1bGFyIiA6ICJDdXN0b21lciBGdWxsIE5hbWUiLCAiRHNfTWVyY2hhbnRfVXJsT0siIDogImh0dHA6Ly9leGFtcGxlLmNhdC9zdWNjZXNzIiwgIkRzX01lcmNoYW50X1VybEtPIiA6ICJodHRwOi8vZXhhbXBsZS5jYXQvZmFpbHVyZSIsICJEc19NZXJjaGFudF9NZXJjaGFudFVSTCIgOiAiaHR0cDovL2V4YW1wbGUuY2F0L25vdGlmeSIsICJEc19NZXJjaGFudF9Db25zdW1lckxhbmd1YWdlIiA6ICIyIn0=', 'uOR3HLlSMH3zrHINAoDv1-MK9tSj5Vo4Og9oAh6Uj08=', 'HMAC_SHA256_V1'),
null::redsys_response
);
select is(
redsys_decode_response(2, 'eyJEc19NZXJjaGFudENvZGUiOiIzNjE3MTY5NjIiLCJEc19UZXJtaW5hbCI6IjAwMSIsIkRzX09yZGVyIjoiNjk5ZmQ2YWRjYTE4IiwiRHNfQW1vdW50IjoiMTAwMCIsIkRzX0N1cnJlbmN5IjoiOTc4IiwiRHNfRGF0ZSI6IjEyXC8wMlwvMjAyNCIsIkRzX0hvdXIiOiIxODowMCIsIkRzX1NlY3VyZVBheW1lbnQiOiIxIiwiRHNfQ2FyZF9Db3VudHJ5IjoiNzI0IiwiRHNfUmVzcG9uc2UiOiIwMDAwIiwiRHNfTWVyY2hhbnREYXRhIjoiIiwiRHNfVHJhbnNhY3Rpb25UeXBlIjoiMCIsIkRzX0NvbnN1bWVyTGFuZ3VhZ2UiOiIyIiwiRHNfQXV0aG9yaXNhdGlvbkNvZGUiOiIyOTM3MzYiLCJEc19DYXJkX0JyYW5kIjoiMSIsIkRzX1Byb2Nlc3NlZFBheU1ldGhvZCI6IjgwIn0=', 'uOR3HLlSMH3zrHINAoDv1-MK9tSj5Vo4Og9oAh6Uj08=', 'HMAC_SHA256_V1'),
null::redsys_response
);
select *
from finish();
rollback;

29
test/redsys_response.sql Normal file
View File

@ -0,0 +1,29 @@
-- Test redsys_response
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(13);
set search_path to camper, public;
select has_composite('camper', 'redsys_response', 'Composite type camper.redsys_response should exist');
select columns_are('camper', 'redsys_response', array['merchant_code', 'terminal_number', 'response_code', 'date_time', 'secure_payment', 'transaction_type', 'amount', 'currency_code', 'order_number', 'authorization_code', 'error_code']);
select col_type_is('camper'::name, 'redsys_response'::name, 'merchant_code'::name, 'text');
select col_type_is('camper'::name, 'redsys_response'::name, 'terminal_number'::name, 'integer');
select col_type_is('camper'::name, 'redsys_response'::name, 'response_code'::name, 'integer');
select col_type_is('camper'::name, 'redsys_response'::name, 'date_time'::name, 'timestamp without time zone');
select col_type_is('camper'::name, 'redsys_response'::name, 'secure_payment'::name, 'boolean');
select col_type_is('camper'::name, 'redsys_response'::name, 'transaction_type'::name, 'integer');
select col_type_is('camper'::name, 'redsys_response'::name, 'amount'::name, 'text');
select col_type_is('camper'::name, 'redsys_response'::name, 'currency_code'::name, 'currency_code');
select col_type_is('camper'::name, 'redsys_response'::name, 'order_number'::name, 'text');
select col_type_is('camper'::name, 'redsys_response'::name, 'authorization_code'::name, 'text');
select col_type_is('camper'::name, 'redsys_response'::name, 'error_code'::name, 'text');
select *
from finish();
rollback;

View File

@ -0,0 +1,7 @@
-- Verify camper:decode_base64url on pg
begin;
select has_function_privilege('camper.decode_base64url(text)', 'execute');
rollback;

View File

@ -0,0 +1,20 @@
-- Verify camper:payment_redsys_response on pg
begin;
select payment_id
, response_code
, date_time
, secure_payment
, transaction_type
, amount
, currency_code
, order_number
, authorization_code
, merchant_code
, terminal_number
, error_code
from camper.payment_redsys_response
where false;
rollback;

View File

@ -0,0 +1,7 @@
-- Verify camper:process_payment_response on pg
begin;
select has_function_privilege('camper.process_payment_response(uuid, camper.redsys_response)', 'execute');
rollback;

View File

@ -0,0 +1,7 @@
-- Verify camper:redsys_decode_response on pg
begin;
select has_function_privilege('camper.redsys_decode_response(integer, text, text, text)', 'execute');
rollback;

View File

@ -0,0 +1,7 @@
-- Verify camper:redsys_response on pg
begin;
select pg_catalog.has_type_privilege('camper.redsys_response', 'usage');
rollback;