Compare commits
4 Commits
1.3.2
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e99a8e0d4 | |||
| fbf652720c | |||
| 478d260ebe | |||
| 2a3a5bee7b |
@ -34,7 +34,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
buildVersion = "1.3.2"
|
||||
buildVersion = "v1.3.5"
|
||||
serviceName = "hardlink"
|
||||
pollingFrequency = 8 * time.Second
|
||||
)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package dispenser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@ -22,9 +23,13 @@ const (
|
||||
|
||||
// cache freshness for "continuous status" reads (tune as you wish)
|
||||
defaultStatusTTL = 1500 * time.Millisecond
|
||||
|
||||
CardWellEmptyMessage = "Card well is empty"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCardWellEmpty = errors.New(CardWellEmptyMessage)
|
||||
|
||||
SerialPort string
|
||||
Address []byte
|
||||
|
||||
@ -67,10 +72,9 @@ var (
|
||||
// Status helpers
|
||||
// --------------------
|
||||
|
||||
func logStatus(statusBytes []byte) {
|
||||
func statusDescription(statusBytes []byte) string {
|
||||
if len(statusBytes) < 4 {
|
||||
log.Infof("Dispenser status: <invalid len=%d>", len(statusBytes))
|
||||
return
|
||||
return fmt.Sprintf("<invalid len=%d>", len(statusBytes))
|
||||
}
|
||||
|
||||
posStatus := []struct {
|
||||
@ -94,13 +98,58 @@ func logStatus(statusBytes []byte) {
|
||||
result.WriteString(statusMsg + "; ")
|
||||
}
|
||||
}
|
||||
log.Infof("Dispenser status: %s", result.String())
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func logStatus(statusBytes []byte) {
|
||||
log.Infof("Dispenser status: %s", statusDescription(statusBytes))
|
||||
}
|
||||
|
||||
func isAtEncoderPosition(statusBytes []byte) bool {
|
||||
return len(statusBytes) >= 4 && statusBytes[3] == 0x33
|
||||
}
|
||||
|
||||
func validateDispenserStatusData(statusBytes []byte) error {
|
||||
if len(statusBytes) != 4 {
|
||||
return fmt.Errorf("malformed dispenser status: got %d bytes, want 4", len(statusBytes))
|
||||
}
|
||||
|
||||
statusMaps := []map[byte]string{statusPos0, statusPos1, statusPos2, statusPos3}
|
||||
for position, mapper := range statusMaps {
|
||||
if _, ok := mapper[statusBytes[position]]; !ok {
|
||||
return fmt.Errorf("unknown dispenser status 0x%X at position %d", statusBytes[position], position+1)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dispenserStatusError(statusBytes []byte) error {
|
||||
switch statusBytes[0] {
|
||||
case 0x34, 0x32, 0x36:
|
||||
return fmt.Errorf("dispenser error: %s", statusPos0[statusBytes[0]])
|
||||
}
|
||||
switch statusBytes[1] {
|
||||
case 0x32, 0x31:
|
||||
return fmt.Errorf("dispenser error: %s", statusPos1[statusBytes[1]])
|
||||
}
|
||||
switch statusBytes[2] {
|
||||
case 0x34, 0x32:
|
||||
return fmt.Errorf("dispenser error: %s", statusPos2[statusBytes[2]])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isPreparationMoving(statusBytes []byte) bool {
|
||||
return len(statusBytes) == 4 &&
|
||||
(statusBytes[0] == 0x31 || statusBytes[1] == 0x38)
|
||||
}
|
||||
|
||||
func hasPreparationDiagnostics(statusBytes []byte) bool {
|
||||
return len(statusBytes) == 4 &&
|
||||
(statusBytes[0] != 0x30 || statusBytes[1] != 0x30 || statusBytes[2] != 0x30)
|
||||
}
|
||||
|
||||
func stockTake(statusBytes []byte) string {
|
||||
if len(statusBytes) < 4 {
|
||||
return ""
|
||||
|
||||
@ -30,12 +30,25 @@ type cmdResp struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type sequenceTiming struct {
|
||||
now func() time.Time
|
||||
wait func(context.Context, time.Duration) error
|
||||
}
|
||||
|
||||
const (
|
||||
sequencePollInterval = time.Second
|
||||
sequenceRetryAfter = 6 * time.Second
|
||||
sequenceTimeout = 12 * time.Second
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
port *serial.Port
|
||||
|
||||
reqCh chan cmdReq
|
||||
done chan struct{}
|
||||
|
||||
sequenceTiming sequenceTiming
|
||||
|
||||
// status cache
|
||||
mu sync.RWMutex
|
||||
lastStatus []byte
|
||||
@ -57,12 +70,28 @@ func NewClient(port *serial.Port, queueSize int) *Client {
|
||||
port: port,
|
||||
reqCh: make(chan cmdReq, queueSize),
|
||||
done: make(chan struct{}),
|
||||
sequenceTiming: sequenceTiming{
|
||||
now: time.Now,
|
||||
wait: waitForSequence,
|
||||
},
|
||||
statusTTL: defaultStatusTTL,
|
||||
}
|
||||
go c.loop()
|
||||
return c
|
||||
}
|
||||
|
||||
func waitForSequence(ctx context.Context, duration time.Duration) error {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Close() {
|
||||
select {
|
||||
case <-c.done:
|
||||
@ -268,113 +297,152 @@ func (c *Client) DispenserPrepare(ctx context.Context) (string, error) {
|
||||
return stockStatus, nil
|
||||
}
|
||||
|
||||
func (c *Client) DispenserStart(ctx context.Context) (string, error) {
|
||||
const funcName = "DispenserStart"
|
||||
stockStatus := ""
|
||||
|
||||
status, err := c.CheckStatus(ctx)
|
||||
func (c *Client) readSequenceStatus(ctx context.Context, operation string) ([]byte, string, error) {
|
||||
status, err := c.do(ctx, cmdStatus)
|
||||
if err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] check status: %w", funcName, err)
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return nil, "", fmt.Errorf("[%s] read status: %w", operation, ctxErr)
|
||||
}
|
||||
return nil, "", fmt.Errorf("[%s] read status: %w", operation, err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
logStatus(status)
|
||||
}()
|
||||
stockStatus := ""
|
||||
if len(status) == 4 {
|
||||
stockStatus = stockTake(status)
|
||||
c.setStock(status)
|
||||
logStatus(status)
|
||||
}
|
||||
return status, stockStatus, nil
|
||||
}
|
||||
|
||||
if isCardWellEmpty(status) {
|
||||
return stockStatus, fmt.Errorf(stockStatus)
|
||||
func preparationStatus(operation string, status []byte) (bool, error) {
|
||||
if len(status) != 4 {
|
||||
return false, fmt.Errorf("[%s] %w", operation, validateDispenserStatusData(status))
|
||||
}
|
||||
if isAtEncoderPosition(status) {
|
||||
return stockStatus, nil
|
||||
if hasPreparationDiagnostics(status) {
|
||||
log.Warnf(
|
||||
"[%s] card confirmed at encoder with dispenser diagnostics: %s raw status: % X",
|
||||
operation,
|
||||
statusDescription(status),
|
||||
status,
|
||||
)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
if err := validateDispenserStatusData(status); err != nil {
|
||||
return false, fmt.Errorf("[%s] %w", operation, err)
|
||||
}
|
||||
if isCardWellEmpty(status) {
|
||||
return false, fmt.Errorf("[%s] %w", operation, ErrCardWellEmpty)
|
||||
}
|
||||
if isPreparationMoving(status) {
|
||||
return false, nil
|
||||
}
|
||||
if err := dispenserStatusError(status); err != nil {
|
||||
return false, fmt.Errorf("[%s] %w", operation, err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := c.ToEncoder(ctx); err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
|
||||
}
|
||||
|
||||
halfway := time.Now().Add(6 * time.Second)
|
||||
deadline := time.Now().Add(12 * time.Second)
|
||||
func (c *Client) pollForEncoderPosition(
|
||||
ctx context.Context,
|
||||
operation string,
|
||||
retryCommand func(context.Context) error,
|
||||
) (string, error) {
|
||||
started := c.sequenceTiming.now()
|
||||
halfway := started.Add(sequenceRetryAfter)
|
||||
deadline := started.Add(sequenceTimeout)
|
||||
retried := false
|
||||
stockStatus := ""
|
||||
|
||||
for {
|
||||
time.Sleep(delay * 2)
|
||||
switch {
|
||||
case time.Now().After(halfway):
|
||||
if err := ctx.Err(); err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] %w", operation, err)
|
||||
}
|
||||
|
||||
now := c.sequenceTiming.now()
|
||||
if !now.Before(deadline) {
|
||||
return stockStatus, fmt.Errorf("[%s] timed out after %s", operation, sequenceTimeout)
|
||||
}
|
||||
|
||||
status, currentStockStatus, err := c.readSequenceStatus(ctx, operation)
|
||||
stockStatus = currentStockStatus
|
||||
if err != nil {
|
||||
return stockStatus, err
|
||||
}
|
||||
ready, err := preparationStatus(operation, status)
|
||||
if err != nil {
|
||||
return stockStatus, err
|
||||
}
|
||||
if ready {
|
||||
return stockStatus, nil
|
||||
}
|
||||
|
||||
now = c.sequenceTiming.now()
|
||||
if !now.Before(deadline) {
|
||||
return stockStatus, fmt.Errorf("[%s] timed out after %s", operation, sequenceTimeout)
|
||||
}
|
||||
if retryCommand != nil && !retried && !now.Before(halfway) {
|
||||
if err := retryCommand(ctx); err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] retry command: %w", operation, err)
|
||||
}
|
||||
retried = true
|
||||
}
|
||||
|
||||
wait := sequencePollInterval
|
||||
if remaining := deadline.Sub(now); remaining < wait {
|
||||
wait = remaining
|
||||
}
|
||||
if err := c.sequenceTiming.wait(ctx, wait); err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] %w", operation, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) prepareCardAtEncoder(ctx context.Context, operation string) (string, error) {
|
||||
status, stockStatus, err := c.readSequenceStatus(ctx, operation)
|
||||
if err != nil {
|
||||
return stockStatus, err
|
||||
}
|
||||
ready, err := preparationStatus(operation, status)
|
||||
if err != nil {
|
||||
return stockStatus, err
|
||||
}
|
||||
if ready {
|
||||
return stockStatus, nil
|
||||
}
|
||||
|
||||
if err := c.ToEncoder(ctx); err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
|
||||
return stockStatus, fmt.Errorf("[%s] to encoder: %w", operation, err)
|
||||
}
|
||||
case time.Now().After(deadline):
|
||||
return stockStatus, nil
|
||||
return c.pollForEncoderPosition(ctx, operation, c.ToEncoder)
|
||||
}
|
||||
|
||||
status, _ = c.do(ctx, cmdStatus)
|
||||
|
||||
stockStatus = stockTake(status)
|
||||
c.setStock(status)
|
||||
logStatus(status)
|
||||
|
||||
// error states first
|
||||
if isCardWellEmpty(status) {
|
||||
return stockStatus, fmt.Errorf(stockStatus)
|
||||
// PrepareCurrentCard authoritatively places the card to be encoded at the encoder.
|
||||
func (c *Client) PrepareCurrentCard(ctx context.Context) (string, error) {
|
||||
return c.prepareCardAtEncoder(ctx, "PrepareCurrentCard")
|
||||
}
|
||||
|
||||
if isAtEncoderPosition(status) {
|
||||
return stockStatus, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) DispenserFinal(ctx context.Context) (string, error) {
|
||||
const funcName = "DispenserFinal"
|
||||
stockStatus := ""
|
||||
var status []byte
|
||||
// DeliverCurrentCard presents the encoded card and confirms command acceptance.
|
||||
func (c *Client) DeliverCurrentCard(ctx context.Context) (string, error) {
|
||||
const operation = "DeliverCurrentCard"
|
||||
|
||||
if err := c.OutOfMouth(ctx); err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] out of mouth: %w", funcName, err)
|
||||
return "", fmt.Errorf("[%s] out of mouth: %w", operation, err)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
time.Sleep(delay)
|
||||
status, err := c.do(ctx, cmdStatus)
|
||||
if err == nil && len(status) >= 4 {
|
||||
c.setStock(status)
|
||||
}
|
||||
|
||||
time.Sleep(delay)
|
||||
// BeginPrepareNextCard starts moving the next card to the encoder without waiting for readiness.
|
||||
func (c *Client) BeginPrepareNextCard(ctx context.Context) error {
|
||||
if err := c.ToEncoder(ctx); err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
|
||||
return fmt.Errorf("[BeginPrepareNextCard] to encoder: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
defer func() {
|
||||
logStatus(status)
|
||||
}()
|
||||
|
||||
halfway := time.Now().Add(6 * time.Second)
|
||||
deadline := time.Now().Add(12 * time.Second)
|
||||
|
||||
for {
|
||||
time.Sleep(delay * 2)
|
||||
switch {
|
||||
case time.Now().After(halfway):
|
||||
if err := c.ToEncoder(ctx); err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
|
||||
}
|
||||
case time.Now().After(deadline):
|
||||
return stockStatus, nil
|
||||
}
|
||||
|
||||
status, _ = c.do(ctx, cmdStatus)
|
||||
|
||||
stockStatus = stockTake(status)
|
||||
c.setStock(status)
|
||||
logStatus(status)
|
||||
|
||||
if isCardWellEmpty(status) {
|
||||
return stockStatus, nil
|
||||
}
|
||||
|
||||
if isAtEncoderPosition(status) {
|
||||
return stockStatus, nil
|
||||
}
|
||||
}
|
||||
// PrepareNextCard places a new card at the encoder for a later issuance attempt.
|
||||
func (c *Client) PrepareNextCard(ctx context.Context) (string, error) {
|
||||
return c.prepareCardAtEncoder(ctx, "PrepareNextCard")
|
||||
}
|
||||
|
||||
404
internal/dispenser/dispenserclient_test.go
Normal file
404
internal/dispenser/dispenserclient_test.go
Normal file
@ -0,0 +1,404 @@
|
||||
package dispenser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type fakeSequenceDevice struct {
|
||||
statusResponses []cmdResp
|
||||
commandErrors map[cmdType][]error
|
||||
commands []cmdType
|
||||
}
|
||||
|
||||
func newSequenceTestClient(t *testing.T, statusResponses ...cmdResp) (*Client, *fakeSequenceDevice) {
|
||||
t.Helper()
|
||||
|
||||
device := &fakeSequenceDevice{
|
||||
statusResponses: append([]cmdResp(nil), statusResponses...),
|
||||
commandErrors: make(map[cmdType][]error),
|
||||
}
|
||||
now := time.Date(2026, time.July, 23, 12, 0, 0, 0, time.UTC)
|
||||
client := &Client{
|
||||
reqCh: make(chan cmdReq),
|
||||
done: make(chan struct{}),
|
||||
sequenceTiming: sequenceTiming{
|
||||
now: func() time.Time {
|
||||
return now
|
||||
},
|
||||
wait: func(ctx context.Context, duration time.Duration) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
now = now.Add(duration)
|
||||
return nil
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-client.done:
|
||||
return
|
||||
case request := <-client.reqCh:
|
||||
device.commands = append(device.commands, request.typ)
|
||||
if request.typ == cmdStatus {
|
||||
if len(device.statusResponses) == 0 {
|
||||
request.respCh <- cmdResp{err: errors.New("unexpected status read")}
|
||||
continue
|
||||
}
|
||||
response := device.statusResponses[0]
|
||||
device.statusResponses = device.statusResponses[1:]
|
||||
request.respCh <- response
|
||||
continue
|
||||
}
|
||||
|
||||
var err error
|
||||
if queued := device.commandErrors[request.typ]; len(queued) > 0 {
|
||||
err = queued[0]
|
||||
device.commandErrors[request.typ] = queued[1:]
|
||||
}
|
||||
request.respCh <- cmdResp{err: err}
|
||||
}
|
||||
}
|
||||
}()
|
||||
t.Cleanup(client.Close)
|
||||
|
||||
return client, device
|
||||
}
|
||||
|
||||
func status(position byte) []byte {
|
||||
return []byte{0x30, 0x30, 0x30, position}
|
||||
}
|
||||
|
||||
func commandCount(commands []cmdType, target cmdType) int {
|
||||
count := 0
|
||||
for _, command := range commands {
|
||||
if command == target {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func TestPrepareCurrentCardAcceptsEncoderPositionWithStaleDiagnostics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status []byte
|
||||
wantStock string
|
||||
}{
|
||||
{
|
||||
name: "dispense error and jam",
|
||||
status: []byte{0x30, 0x32, 0x32, 0x33},
|
||||
wantStock: "Card jammed",
|
||||
},
|
||||
{
|
||||
name: "supply diagnostics",
|
||||
status: []byte{0x32, 0x30, 0x31, 0x33},
|
||||
wantStock: "Card pre-empty",
|
||||
},
|
||||
{
|
||||
name: "combined stale jam and supply diagnostics",
|
||||
status: []byte{0x30, 0x32, 0x33, 0x33},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var logOutput bytes.Buffer
|
||||
standardLogger := log.StandardLogger()
|
||||
previousOutput := standardLogger.Out
|
||||
standardLogger.SetOutput(&logOutput)
|
||||
t.Cleanup(func() {
|
||||
standardLogger.SetOutput(previousOutput)
|
||||
})
|
||||
|
||||
client, device := newSequenceTestClient(t, cmdResp{status: test.status})
|
||||
|
||||
stock, err := client.PrepareCurrentCard(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stock != test.wantStock {
|
||||
t.Fatalf("stock status = %q, want %q", stock, test.wantStock)
|
||||
}
|
||||
if got := commandCount(device.commands, cmdToEncoder); got != 0 {
|
||||
t.Fatalf("to-encoder commands = %d, want 0", got)
|
||||
}
|
||||
if got := commandCount(device.commands, cmdStatus); got != 1 {
|
||||
t.Fatalf("status reads = %d, want 1", got)
|
||||
}
|
||||
logged := logOutput.String()
|
||||
if !strings.Contains(logged, "card confirmed at encoder") ||
|
||||
!strings.Contains(logged, statusDescription(test.status)) ||
|
||||
!strings.Contains(logged, "raw status") {
|
||||
t.Fatalf("encoder diagnostic log = %q", logged)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCurrentCardPollingAllowsTransientMovementWithStaleErrors(t *testing.T) {
|
||||
client, device := newSequenceTestClient(t,
|
||||
cmdResp{status: status(0x34)},
|
||||
cmdResp{status: []byte{0x31, 0x38, 0x32, 0x30}},
|
||||
cmdResp{status: []byte{0x30, 0x32, 0x32, 0x33}},
|
||||
)
|
||||
|
||||
stock, err := client.PrepareCurrentCard(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stock != "Card jammed" {
|
||||
t.Fatalf("stock status = %q, want Card jammed", stock)
|
||||
}
|
||||
if got := commandCount(device.commands, cmdToEncoder); got != 1 {
|
||||
t.Fatalf("to-encoder commands = %d, want 1", got)
|
||||
}
|
||||
if got := commandCount(device.commands, cmdStatus); got != 3 {
|
||||
t.Fatalf("status reads = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCurrentCardRejectsFailureWithoutEncoderOrMovement(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response cmdResp
|
||||
wantError string
|
||||
wantStock string
|
||||
}{
|
||||
{
|
||||
name: "status read error",
|
||||
response: cmdResp{err: errors.New("serial read failed")},
|
||||
wantError: "serial read failed",
|
||||
},
|
||||
{
|
||||
name: "malformed status",
|
||||
response: cmdResp{status: []byte{0x30, 0x30, 0x31}},
|
||||
wantError: "malformed dispenser status",
|
||||
},
|
||||
{
|
||||
name: "unknown status",
|
||||
response: cmdResp{status: []byte{0x30, 0x30, 0x30, 0x39}},
|
||||
wantError: "unknown dispenser status",
|
||||
},
|
||||
{
|
||||
name: "jammed preparation",
|
||||
response: cmdResp{status: []byte{0x30, 0x30, 0x32, 0x34}},
|
||||
wantError: "Card jammed",
|
||||
wantStock: "Card jammed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
client, _ := newSequenceTestClient(t, test.response)
|
||||
|
||||
stock, err := client.PrepareCurrentCard(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
||||
t.Fatalf("error = %v, want containing %q", err, test.wantError)
|
||||
}
|
||||
if stock != test.wantStock {
|
||||
t.Fatalf("stock status = %q, want %q", stock, test.wantStock)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCurrentCardReturnsAuthoritativeEmptyWellError(t *testing.T) {
|
||||
client, device := newSequenceTestClient(t, cmdResp{status: status(0x38)})
|
||||
|
||||
stock, err := client.PrepareCurrentCard(context.Background())
|
||||
if !errors.Is(err, ErrCardWellEmpty) {
|
||||
t.Fatalf("error = %v, want ErrCardWellEmpty", err)
|
||||
}
|
||||
if stock != "Card empty" {
|
||||
t.Fatalf("stock status = %q, want Card empty", stock)
|
||||
}
|
||||
if got := commandCount(device.commands, cmdToEncoder); got != 0 {
|
||||
t.Fatalf("to-encoder commands = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverCurrentCardSucceedsAfterCommandAcceptanceWithoutStatusPolling(t *testing.T) {
|
||||
staleStatus := []byte{0x30, 0x32, 0x32, 0x33}
|
||||
client, device := newSequenceTestClient(t, cmdResp{status: staleStatus})
|
||||
|
||||
stock, err := client.DeliverCurrentCard(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stock != "" {
|
||||
t.Fatalf("stock status = %q, want empty", stock)
|
||||
}
|
||||
if got := commandCount(device.commands, cmdOutOfMouth); got != 1 {
|
||||
t.Fatalf("out-of-mouth commands = %d, want 1", got)
|
||||
}
|
||||
if got := commandCount(device.commands, cmdStatus); got != 0 {
|
||||
t.Fatalf("status reads = %d, want 0", got)
|
||||
}
|
||||
if len(device.statusResponses) != 1 {
|
||||
t.Fatalf("queued status responses = %d, want stale status unread", len(device.statusResponses))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverCurrentCardRejectsCommandFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{name: "serial dispatch failure", err: errors.New("serial write failed")},
|
||||
{name: "invalid acknowledgement", err: errors.New("unexpected response status")},
|
||||
{name: "missing acknowledgement", err: errors.New("no response from dispenser")},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
client, device := newSequenceTestClient(t)
|
||||
device.commandErrors[cmdOutOfMouth] = []error{test.err}
|
||||
|
||||
_, err := client.DeliverCurrentCard(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), test.err.Error()) {
|
||||
t.Fatalf("error = %v, want containing %q", err, test.err)
|
||||
}
|
||||
if got := commandCount(device.commands, cmdOutOfMouth); got != 1 {
|
||||
t.Fatalf("out-of-mouth commands = %d, want 1", got)
|
||||
}
|
||||
if got := commandCount(device.commands, cmdStatus); got != 0 {
|
||||
t.Fatalf("status reads = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverCurrentCardPreservesContextCancellation(t *testing.T) {
|
||||
client, _ := newSequenceTestClient(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := client.DeliverCurrentCard(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCurrentCardRetriesOnceThenTimesOut(t *testing.T) {
|
||||
responses := make([]cmdResp, 13)
|
||||
for index := range responses {
|
||||
responses[index] = cmdResp{status: status(0x34)}
|
||||
}
|
||||
client, device := newSequenceTestClient(t, responses...)
|
||||
|
||||
_, err := client.PrepareCurrentCard(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "timed out") {
|
||||
t.Fatalf("error = %v, want preparation timeout", err)
|
||||
}
|
||||
if got := commandCount(device.commands, cmdToEncoder); got != 2 {
|
||||
t.Fatalf("to-encoder commands = %d, want initial command plus one halfway retry", got)
|
||||
}
|
||||
if got := commandCount(device.commands, cmdStatus); got != 13 {
|
||||
t.Fatalf("status reads = %d, want 13", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCurrentCardPropagatesContextCancellation(t *testing.T) {
|
||||
client, _ := newSequenceTestClient(t,
|
||||
cmdResp{status: status(0x34)},
|
||||
cmdResp{status: status(0x34)},
|
||||
)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
client.sequenceTiming.wait = func(context.Context, time.Duration) error {
|
||||
cancel()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
_, err := client.PrepareCurrentCard(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeginPrepareNextCardDispatchesWithoutReadinessPolling(t *testing.T) {
|
||||
client, device := newSequenceTestClient(t)
|
||||
|
||||
if err := client.BeginPrepareNextCard(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := commandCount(device.commands, cmdToEncoder); got != 1 {
|
||||
t.Fatalf("to-encoder commands = %d, want 1", got)
|
||||
}
|
||||
if got := commandCount(device.commands, cmdStatus); got != 0 {
|
||||
t.Fatalf("status reads = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeginPrepareNextCardReturnsDispatchFailureWithoutPolling(t *testing.T) {
|
||||
client, device := newSequenceTestClient(t)
|
||||
device.commandErrors[cmdToEncoder] = []error{errors.New("dispatch failed")}
|
||||
|
||||
err := client.BeginPrepareNextCard(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "dispatch failed") {
|
||||
t.Fatalf("error = %v, want dispatch failure", err)
|
||||
}
|
||||
if got := commandCount(device.commands, cmdToEncoder); got != 1 {
|
||||
t.Fatalf("to-encoder commands = %d, want 1", got)
|
||||
}
|
||||
if got := commandCount(device.commands, cmdStatus); got != 0 {
|
||||
t.Fatalf("status reads = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCardAtEncoderRequiresConfirmedEncoderState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responses []cmdResp
|
||||
wantError string
|
||||
wantToEncoder int
|
||||
}{
|
||||
{
|
||||
name: "already at encoder",
|
||||
responses: []cmdResp{{status: status(0x33)}},
|
||||
wantToEncoder: 0,
|
||||
},
|
||||
{
|
||||
name: "moves ready card to encoder",
|
||||
responses: []cmdResp{
|
||||
{status: status(0x34)},
|
||||
{status: status(0x33)},
|
||||
},
|
||||
wantToEncoder: 1,
|
||||
},
|
||||
{
|
||||
name: "empty card well",
|
||||
responses: []cmdResp{{status: status(0x38)}},
|
||||
wantError: CardWellEmptyMessage,
|
||||
wantToEncoder: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
client, device := newSequenceTestClient(t, test.responses...)
|
||||
|
||||
_, err := client.PrepareNextCard(context.Background())
|
||||
if test.wantError == "" && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if test.wantError != "" && (err == nil || !strings.Contains(err.Error(), test.wantError)) {
|
||||
t.Fatalf("error = %v, want containing %q", err, test.wantError)
|
||||
}
|
||||
if got := commandCount(device.commands, cmdToEncoder); got != test.wantToEncoder {
|
||||
t.Fatalf("to-encoder commands = %d, want %d", got, test.wantToEncoder)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -4,15 +4,18 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@ -33,6 +36,43 @@ type Client struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
const (
|
||||
dojoTerminalUnavailableStatus = "TERMINAL_UNAVAILABLE"
|
||||
dojoTerminalUnavailableMessage = "Payment terminal is unavailable"
|
||||
)
|
||||
|
||||
type httpResponseError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *httpResponseError) Error() string {
|
||||
return fmt.Sprintf("Dojo returned HTTP %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
type terminalSessionWithUpdates struct {
|
||||
terminalSessionResponse
|
||||
|
||||
NotificationEvents []terminalNotificationEvent `json:"notificationEvents"`
|
||||
StatusEvents []terminalStatusEvent `json:"statusEvents"`
|
||||
}
|
||||
|
||||
type terminalNotificationEvent struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
NotificationType string `json:"notificationType"`
|
||||
}
|
||||
|
||||
type terminalStatusEvent struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type terminalDisplayEvent struct {
|
||||
createdAt time.Time
|
||||
code string
|
||||
sessionStatus string
|
||||
}
|
||||
|
||||
func NewClient(cfg Config) (*Client, error) {
|
||||
if cfg.BaseURL == "" {
|
||||
return nil, fmt.Errorf("dojo base_url is required")
|
||||
@ -60,11 +100,17 @@ func NewClient(cfg Config) (*Client, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Sale(ctx context.Context, req paymentsvc.SaleRequest) (*paymentsvc.Result, error) {
|
||||
func (c *Client) Sale(
|
||||
ctx context.Context,
|
||||
req paymentsvc.SaleRequest,
|
||||
onStatus paymentsvc.StatusHandler,
|
||||
) (*paymentsvc.Result, error) {
|
||||
if req.Currency == "" {
|
||||
req.Currency = "GBP"
|
||||
}
|
||||
|
||||
sendPaymentStatus(onStatus, paymentstatus.Starting)
|
||||
|
||||
intent, err := c.createPaymentIntent(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -72,10 +118,25 @@ func (c *Client) Sale(ctx context.Context, req paymentsvc.SaleRequest) (*payment
|
||||
|
||||
session, err := c.createTerminalSession(ctx, intent.ID)
|
||||
if err != nil {
|
||||
var responseErr *httpResponseError
|
||||
if errors.As(err, &responseErr) && responseErr.StatusCode == http.StatusConflict {
|
||||
log.WithFields(log.Fields{
|
||||
"provider": "Dojo",
|
||||
"operation": "createTerminalSession",
|
||||
"status": responseErr.StatusCode,
|
||||
"terminal_unavailable": true,
|
||||
}).Warn(dojoTerminalUnavailableMessage)
|
||||
|
||||
sendPaymentStatus(onStatus, paymentstatus.TerminalUnavailable)
|
||||
result := c.baseResult(req, nil)
|
||||
result.Status = dojoTerminalUnavailableStatus
|
||||
result.ErrorMessage = dojoTerminalUnavailableMessage
|
||||
return result, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
session, err = c.waitForTerminalSession(ctx, session.ID)
|
||||
session, err = c.waitForTerminalSession(ctx, session.ID, onStatus)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -150,10 +211,17 @@ func (c *Client) createTerminalSession(ctx context.Context, paymentIntentID stri
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func (c *Client) waitForTerminalSession(ctx context.Context, terminalSessionID string) (*terminalSessionResponse, error) {
|
||||
func (c *Client) waitForTerminalSession(
|
||||
ctx context.Context,
|
||||
terminalSessionID string,
|
||||
onStatus paymentsvc.StatusHandler,
|
||||
) (*terminalSessionResponse, error) {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
notificationCount := 0
|
||||
statusCount := 0
|
||||
lastSessionStatus := ""
|
||||
signatureRejected := false
|
||||
|
||||
for {
|
||||
@ -164,12 +232,40 @@ func (c *Client) waitForTerminalSession(ctx context.Context, terminalSessionID s
|
||||
|
||||
log.Println("session status:", session.Status)
|
||||
|
||||
switch strings.ToLower(session.Status) {
|
||||
case types.ResultInitiateRequested, types.ResultInitiated, types.ResultAuthorized, types.ResultCancelRequested:
|
||||
emitNewTerminalSessionEvents(
|
||||
session,
|
||||
¬ificationCount,
|
||||
&statusCount,
|
||||
&lastSessionStatus,
|
||||
onStatus,
|
||||
)
|
||||
|
||||
currentStatus := strings.ToLower(session.Status)
|
||||
if currentStatus != lastSessionStatus {
|
||||
lastSessionStatus = currentStatus
|
||||
sendPaymentStatus(
|
||||
onStatus,
|
||||
mapDojoSessionStatus(session.Status),
|
||||
)
|
||||
}
|
||||
|
||||
switch currentStatus {
|
||||
case types.ResultInitiateRequested,
|
||||
types.ResultInitiated,
|
||||
types.ResultAuthorized,
|
||||
types.ResultCancelRequested:
|
||||
|
||||
case types.ResultSignatureRequired:
|
||||
if !signatureRejected {
|
||||
rejectedSession, err := c.rejectSignature(ctx, terminalSessionID)
|
||||
sendPaymentStatus(
|
||||
onStatus,
|
||||
paymentstatus.SignatureRejecting,
|
||||
)
|
||||
|
||||
rejectedSession, err := c.rejectSignature(
|
||||
ctx,
|
||||
terminalSessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -177,30 +273,206 @@ func (c *Client) waitForTerminalSession(ctx context.Context, terminalSessionID s
|
||||
signatureRejected = true
|
||||
|
||||
if rejectedSession != nil {
|
||||
switch strings.ToLower(rejectedSession.Status) {
|
||||
case types.ResultCaptured, types.ResultCancelled, types.ResultCanceled, types.ResultDeclined,
|
||||
types.ResultExpired, types.ResultSignatureAccepted, types.ResultSignatureRejected:
|
||||
rejectedStatus := strings.ToLower(
|
||||
rejectedSession.Status,
|
||||
)
|
||||
|
||||
if rejectedStatus != lastSessionStatus {
|
||||
lastSessionStatus = rejectedStatus
|
||||
sendPaymentStatus(
|
||||
onStatus,
|
||||
mapDojoSessionStatus(
|
||||
rejectedSession.Status,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
switch rejectedStatus {
|
||||
case types.ResultCaptured,
|
||||
types.ResultCancelled,
|
||||
types.ResultCanceled,
|
||||
types.ResultDeclined,
|
||||
types.ResultExpired,
|
||||
types.ResultSignatureAccepted,
|
||||
types.ResultSignatureRejected:
|
||||
return rejectedSession, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case types.ResultCaptured, types.ResultCancelled, types.ResultCanceled, types.ResultDeclined,
|
||||
types.ResultExpired, types.ResultSignatureAccepted, types.ResultSignatureRejected:
|
||||
return session, nil
|
||||
case types.ResultCaptured,
|
||||
types.ResultCancelled,
|
||||
types.ResultCanceled,
|
||||
types.ResultDeclined,
|
||||
types.ResultExpired,
|
||||
types.ResultSignatureAccepted,
|
||||
types.ResultSignatureRejected:
|
||||
return &session.terminalSessionResponse, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected Dojo terminal session status %q", session.Status)
|
||||
return nil, fmt.Errorf(
|
||||
"unexpected Dojo terminal session status %q",
|
||||
session.Status,
|
||||
)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func emitNewTerminalSessionEvents(
|
||||
session *terminalSessionWithUpdates,
|
||||
notificationCount *int,
|
||||
statusCount *int,
|
||||
lastSessionStatus *string,
|
||||
onStatus paymentsvc.StatusHandler,
|
||||
) {
|
||||
if *notificationCount > len(session.NotificationEvents) {
|
||||
*notificationCount = len(session.NotificationEvents)
|
||||
}
|
||||
if *statusCount > len(session.StatusEvents) {
|
||||
*statusCount = len(session.StatusEvents)
|
||||
}
|
||||
|
||||
newEventCount := len(session.NotificationEvents) - *notificationCount +
|
||||
len(session.StatusEvents) - *statusCount
|
||||
events := make([]terminalDisplayEvent, 0, newEventCount)
|
||||
|
||||
// Add status events first so a notification wins when timestamps are equal.
|
||||
for *statusCount < len(session.StatusEvents) {
|
||||
event := session.StatusEvents[*statusCount]
|
||||
*statusCount = *statusCount + 1
|
||||
|
||||
events = append(events, terminalDisplayEvent{
|
||||
createdAt: event.CreatedAt,
|
||||
code: mapDojoSessionStatus(event.Status),
|
||||
sessionStatus: event.Status,
|
||||
})
|
||||
}
|
||||
|
||||
for *notificationCount < len(session.NotificationEvents) {
|
||||
event := session.NotificationEvents[*notificationCount]
|
||||
*notificationCount = *notificationCount + 1
|
||||
|
||||
events = append(events, terminalDisplayEvent{
|
||||
createdAt: event.CreatedAt,
|
||||
code: mapDojoNotification(event.NotificationType),
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(events, func(i, j int) bool {
|
||||
return events[i].createdAt.Before(events[j].createdAt)
|
||||
})
|
||||
|
||||
for _, event := range events {
|
||||
if event.sessionStatus != "" {
|
||||
*lastSessionStatus = strings.ToLower(event.sessionStatus)
|
||||
}
|
||||
|
||||
sendPaymentStatus(onStatus, event.code)
|
||||
}
|
||||
}
|
||||
|
||||
func sendPaymentStatus(
|
||||
handler paymentsvc.StatusHandler,
|
||||
code string,
|
||||
) {
|
||||
if handler == nil || code == "" {
|
||||
return
|
||||
}
|
||||
|
||||
handler(paymentsvc.StatusUpdate{
|
||||
Code: code,
|
||||
})
|
||||
}
|
||||
|
||||
func mapDojoNotification(notification string) string {
|
||||
switch notification {
|
||||
case "PresentCard":
|
||||
return paymentstatus.PresentCard
|
||||
|
||||
case "InsertCard":
|
||||
return paymentstatus.InsertCard
|
||||
|
||||
case "SwipeCard":
|
||||
return paymentstatus.SwipeCard
|
||||
|
||||
case "EnterPin":
|
||||
return paymentstatus.EnterPIN
|
||||
|
||||
case "RemoveCard":
|
||||
return paymentstatus.RemoveCard
|
||||
|
||||
case "PleaseWait":
|
||||
return paymentstatus.PleaseWait
|
||||
|
||||
default:
|
||||
return dojoFallbackStatusCode(
|
||||
paymentstatus.DojoNotificationPrefix,
|
||||
notification,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func mapDojoSessionStatus(status string) string {
|
||||
switch strings.ToLower(status) {
|
||||
case types.ResultInitiateRequested:
|
||||
return paymentstatus.Starting
|
||||
|
||||
case types.ResultInitiated:
|
||||
return paymentstatus.Started
|
||||
|
||||
case types.ResultAuthorized:
|
||||
return paymentstatus.Authorized
|
||||
|
||||
case types.ResultCancelRequested:
|
||||
return paymentstatus.Cancelling
|
||||
|
||||
case types.ResultSignatureRequired:
|
||||
return paymentstatus.SignatureRequired
|
||||
|
||||
case types.ResultCaptured, types.ResultSignatureAccepted:
|
||||
return paymentstatus.Approved
|
||||
|
||||
case types.ResultCancelled, types.ResultCanceled:
|
||||
return paymentstatus.Cancelled
|
||||
|
||||
case types.ResultDeclined:
|
||||
return paymentstatus.Declined
|
||||
|
||||
case types.ResultExpired:
|
||||
return paymentstatus.Expired
|
||||
|
||||
case types.ResultSignatureRejected:
|
||||
return paymentstatus.SignatureRejected
|
||||
|
||||
default:
|
||||
return dojoFallbackStatusCode(
|
||||
paymentstatus.DojoStatusPrefix,
|
||||
status,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func dojoFallbackStatusCode(prefix, value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
replacer := strings.NewReplacer(
|
||||
" ", "_",
|
||||
"-", "_",
|
||||
)
|
||||
|
||||
return prefix + strings.ToUpper(replacer.Replace(value))
|
||||
}
|
||||
|
||||
func (c *Client) rejectSignature(ctx context.Context, terminalSessionID string) (*terminalSessionResponse, error) {
|
||||
payload := signatureVerificationRequest{
|
||||
Accepted: false,
|
||||
@ -216,8 +488,11 @@ func (c *Client) rejectSignature(ctx context.Context, terminalSessionID string)
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func (c *Client) getTerminalSession(ctx context.Context, terminalSessionID string) (*terminalSessionResponse, error) {
|
||||
var response terminalSessionResponse
|
||||
func (c *Client) getTerminalSession(
|
||||
ctx context.Context,
|
||||
terminalSessionID string,
|
||||
) (*terminalSessionWithUpdates, error) {
|
||||
var response terminalSessionWithUpdates
|
||||
path := "/terminal-sessions/" + url.PathEscape(terminalSessionID)
|
||||
if err := c.doJSON(ctx, http.MethodGet, path, nil, true, &response); err != nil {
|
||||
return nil, fmt.Errorf("get Dojo terminal session: %w", err)
|
||||
@ -324,10 +599,13 @@ func (c *Client) doJSON(ctx context.Context, method, path string, payload any, t
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
// log.Println("Dojo payment result raw:", string(responseBody))
|
||||
if resp.StatusCode < http.StatusOK ||
|
||||
resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return fmt.Errorf("Dojo returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody)))
|
||||
return &httpResponseError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: strings.TrimSpace(string(responseBody)),
|
||||
}
|
||||
}
|
||||
|
||||
if target == nil || len(responseBody) == 0 {
|
||||
|
||||
199
internal/dojo/client_test.go
Normal file
199
internal/dojo/client_test.go
Normal file
@ -0,0 +1,199 @@
|
||||
package dojo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
|
||||
)
|
||||
|
||||
const terminalUnavailableBody = `{"detail":"the terminal is either offline or currently in use","errors":{},"status":409,"title":"terminal unavailable","traceId":"trace-secret","type":"https://docs.dojo.tech/problems/terminal-unavailable"}`
|
||||
|
||||
func TestDoJSONReturnsStructuredHTTPResponseError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(" " + terminalUnavailableBody + " "))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newDojoTestClient(t, server)
|
||||
err := client.doJSON(context.Background(), http.MethodPost, "/payment-intents", nil, false, nil)
|
||||
|
||||
var responseErr *httpResponseError
|
||||
if !errors.As(err, &responseErr) {
|
||||
t.Fatalf("error = %T %v, want *httpResponseError", err, err)
|
||||
}
|
||||
if responseErr.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("StatusCode = %d, want %d", responseErr.StatusCode, http.StatusConflict)
|
||||
}
|
||||
if responseErr.Body != terminalUnavailableBody {
|
||||
t.Fatalf("Body = %q, want retained trimmed response", responseErr.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaleMapsCreateTerminalSessionConflictToTerminalUnavailable(t *testing.T) {
|
||||
var paymentIntentRequests atomic.Int32
|
||||
var terminalSessionRequests atomic.Int32
|
||||
var unexpectedRequests atomic.Int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/payment-intents":
|
||||
paymentIntentRequests.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintln(w, `{"id":"intent-123"}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/terminal-sessions":
|
||||
terminalSessionRequests.Add(1)
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = fmt.Fprintln(w, terminalUnavailableBody)
|
||||
default:
|
||||
unexpectedRequests.Add(1)
|
||||
http.Error(w, "unexpected request", http.StatusInternalServerError)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newDojoTestClient(t, server)
|
||||
request := paymentsvc.SaleRequest{
|
||||
RequestID: "REQ-123",
|
||||
Reference: "BOOKING-123",
|
||||
Amount: 10852,
|
||||
Currency: "GBP",
|
||||
}
|
||||
var statuses []string
|
||||
result, err := client.Sale(context.Background(), request, func(update paymentsvc.StatusUpdate) {
|
||||
statuses = append(statuses, update.Code)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("Sale returned nil result")
|
||||
}
|
||||
if result.Success || result.Status == "APPROVED" {
|
||||
t.Fatalf("result was accidentally successful: %+v", result)
|
||||
}
|
||||
if result.Status != dojoTerminalUnavailableStatus || result.ErrorMessage != dojoTerminalUnavailableMessage {
|
||||
t.Fatalf("terminal-unavailable result = %+v", result)
|
||||
}
|
||||
if result.RequestID != request.RequestID ||
|
||||
result.Operation != "SALE" ||
|
||||
result.Amount != request.Amount ||
|
||||
result.Currency != request.Currency ||
|
||||
result.DeviceUsed != "terminal-1" ||
|
||||
result.DeviceType != "Dojo Terminal" {
|
||||
t.Fatalf("base result fields were not retained: %+v", result)
|
||||
}
|
||||
if result.TransactionID != "" || result.CustomerReceipt != "" || result.MerchantReceipt != "" {
|
||||
t.Fatalf("terminal-unavailable result invented transaction data: %+v", result)
|
||||
}
|
||||
if !reflect.DeepEqual(statuses, []string{paymentstatus.Starting, paymentstatus.TerminalUnavailable}) {
|
||||
t.Fatalf("statuses = %#v", statuses)
|
||||
}
|
||||
if paymentIntentRequests.Load() != 1 || terminalSessionRequests.Load() != 1 || unexpectedRequests.Load() != 0 {
|
||||
t.Fatalf(
|
||||
"requests: intent=%d terminal=%d unexpected=%d",
|
||||
paymentIntentRequests.Load(),
|
||||
terminalSessionRequests.Load(),
|
||||
unexpectedRequests.Load(),
|
||||
)
|
||||
}
|
||||
|
||||
resultText := fmt.Sprintf("%+v", result)
|
||||
for _, forbidden := range []string{"offline or currently in use", "trace-secret", "docs.dojo.tech", terminalUnavailableBody} {
|
||||
if strings.Contains(resultText, forbidden) {
|
||||
t.Fatalf("result exposed %q: %s", forbidden, resultText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaleDoesNotClassifyPaymentIntentConflictAsTerminalUnavailable(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/payment-intents" {
|
||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = fmt.Fprintln(w, terminalUnavailableBody)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newDojoTestClient(t, server)
|
||||
var statuses []string
|
||||
result, err := client.Sale(context.Background(), paymentsvc.SaleRequest{
|
||||
RequestID: "REQ-123",
|
||||
Amount: 10852,
|
||||
Currency: "GBP",
|
||||
}, func(update paymentsvc.StatusUpdate) {
|
||||
statuses = append(statuses, update.Code)
|
||||
})
|
||||
|
||||
if result != nil || err == nil {
|
||||
t.Fatalf("Sale result/error = %+v/%v, want nil generic error", result, err)
|
||||
}
|
||||
var responseErr *httpResponseError
|
||||
if !errors.As(err, &responseErr) || responseErr.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("error = %T %v, want wrapped HTTP 409", err, err)
|
||||
}
|
||||
if !reflect.DeepEqual(statuses, []string{paymentstatus.Starting}) {
|
||||
t.Fatalf("statuses = %#v, want only PAYMENT_STARTING", statuses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaleRetainsGenericCreateTerminalSessionErrors(t *testing.T) {
|
||||
for _, statusCode := range []int{http.StatusBadRequest, http.StatusBadGateway} {
|
||||
t.Run(http.StatusText(statusCode), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/payment-intents":
|
||||
_, _ = fmt.Fprintln(w, `{"id":"intent-123"}`)
|
||||
case "/terminal-sessions":
|
||||
w.WriteHeader(statusCode)
|
||||
_, _ = fmt.Fprintln(w, `{"detail":"provider failure"}`)
|
||||
default:
|
||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newDojoTestClient(t, server)
|
||||
result, err := client.Sale(context.Background(), paymentsvc.SaleRequest{
|
||||
RequestID: "REQ-123",
|
||||
Amount: 10852,
|
||||
Currency: "GBP",
|
||||
}, nil)
|
||||
|
||||
if result != nil || err == nil {
|
||||
t.Fatalf("Sale result/error = %+v/%v, want nil generic error", result, err)
|
||||
}
|
||||
var responseErr *httpResponseError
|
||||
if !errors.As(err, &responseErr) || responseErr.StatusCode != statusCode {
|
||||
t.Fatalf("error = %T %v, want wrapped HTTP %d", err, err, statusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newDojoTestClient(t *testing.T, server *httptest.Server) *Client {
|
||||
t.Helper()
|
||||
client, err := NewClient(Config{
|
||||
BaseURL: server.URL,
|
||||
APIKey: "test-api-key",
|
||||
SoftwareHouseID: "software-house-1",
|
||||
TerminalID: "terminal-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client.httpClient = server.Client()
|
||||
return client
|
||||
}
|
||||
313
internal/handlers/doorcard_handlers_test.go
Normal file
313
internal/handlers/doorcard_handlers_test.go
Normal file
@ -0,0 +1,313 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/config"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/dispenser"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type dispenserCallResult struct {
|
||||
status string
|
||||
err error
|
||||
}
|
||||
|
||||
type fakeDoorCardDispenser struct {
|
||||
prepareCurrent dispenserCallResult
|
||||
deliverCurrent dispenserCallResult
|
||||
beginNextErr error
|
||||
beginNext func() error
|
||||
prepareNext dispenserCallResult
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (d *fakeDoorCardDispenser) PrepareCurrentCard(context.Context) (string, error) {
|
||||
d.calls = append(d.calls, "prepare current")
|
||||
return d.prepareCurrent.status, d.prepareCurrent.err
|
||||
}
|
||||
|
||||
func (d *fakeDoorCardDispenser) DeliverCurrentCard(context.Context) (string, error) {
|
||||
d.calls = append(d.calls, "deliver current")
|
||||
return d.deliverCurrent.status, d.deliverCurrent.err
|
||||
}
|
||||
|
||||
func (d *fakeDoorCardDispenser) BeginPrepareNextCard(context.Context) error {
|
||||
d.calls = append(d.calls, "begin prepare next")
|
||||
if d.beginNext != nil {
|
||||
return d.beginNext()
|
||||
}
|
||||
return d.beginNextErr
|
||||
}
|
||||
|
||||
func (d *fakeDoorCardDispenser) PrepareNextCard(context.Context) (string, error) {
|
||||
d.calls = append(d.calls, "prepare next")
|
||||
return d.prepareNext.status, d.prepareNext.err
|
||||
}
|
||||
|
||||
type fakeDoorCardLockServer struct {
|
||||
sequenceErr error
|
||||
buildCalls int
|
||||
sequenceCalls int
|
||||
}
|
||||
|
||||
func (l *fakeDoorCardLockServer) BuildCommand(lockserver.DoorCardRequest, time.Time, time.Time) error {
|
||||
l.buildCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *fakeDoorCardLockServer) LockSequence() error {
|
||||
l.sequenceCalls++
|
||||
return l.sequenceErr
|
||||
}
|
||||
|
||||
func performIssueDoorCardRequest(
|
||||
t *testing.T,
|
||||
dispenser *fakeDoorCardDispenser,
|
||||
lock *fakeDoorCardLockServer,
|
||||
) (*httptest.ResponseRecorder, cmstypes.StatusRec, *App) {
|
||||
t.Helper()
|
||||
|
||||
payload := lockserver.DoorCardRequest{
|
||||
RoomField: "101",
|
||||
CheckinTime: "2026-07-23 15:00:00 +0100",
|
||||
CheckoutTime: "2026-07-24 11:00:00 +0100",
|
||||
FollowStr: "0",
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := &App{
|
||||
disp: dispenser,
|
||||
lockserver: lock,
|
||||
cfg: &config.ConfigRec{},
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/issuedoorcard", bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
app.issueDoorCard(recorder, request)
|
||||
|
||||
var response cmstypes.StatusRec
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response %q: %v", recorder.Body.String(), err)
|
||||
}
|
||||
return recorder, response, app
|
||||
}
|
||||
|
||||
func TestIssueDoorCardPhysicalOutcomeContract(t *testing.T) {
|
||||
encodingErr := errors.New("key encoding failed")
|
||||
tests := []struct {
|
||||
name string
|
||||
dispenser fakeDoorCardDispenser
|
||||
lockErr error
|
||||
wantHTTP int
|
||||
wantMessage string
|
||||
wantCalls []string
|
||||
wantLockSequence int
|
||||
wantCardWell string
|
||||
}{
|
||||
{
|
||||
name: "initial dispenser preparation failure is unavailable",
|
||||
dispenser: fakeDoorCardDispenser{
|
||||
prepareCurrent: dispenserCallResult{status: "Card jammed", err: errors.New("card jammed")},
|
||||
},
|
||||
wantHTTP: http.StatusServiceUnavailable,
|
||||
wantMessage: "Dispense error: card jammed",
|
||||
wantCalls: []string{"prepare current"},
|
||||
wantCardWell: "Card jammed",
|
||||
},
|
||||
{
|
||||
name: "initial empty card well has stable message",
|
||||
dispenser: fakeDoorCardDispenser{
|
||||
prepareCurrent: dispenserCallResult{status: "Card empty", err: dispenser.ErrCardWellEmpty},
|
||||
},
|
||||
wantHTTP: http.StatusServiceUnavailable,
|
||||
wantMessage: dispenser.CardWellEmptyMessage,
|
||||
wantCalls: []string{"prepare current"},
|
||||
wantCardWell: "Card empty",
|
||||
},
|
||||
{
|
||||
name: "encoding and accepted delivery command succeed",
|
||||
wantHTTP: http.StatusOK,
|
||||
wantMessage: "Card issued successfully",
|
||||
wantCalls: []string{"prepare current", "deliver current", "begin prepare next"},
|
||||
wantLockSequence: 1,
|
||||
},
|
||||
{
|
||||
name: "successful encoding with delivery command failure is unavailable",
|
||||
dispenser: fakeDoorCardDispenser{
|
||||
deliverCurrent: dispenserCallResult{status: "Card jammed", err: errors.New("delivery jammed")},
|
||||
},
|
||||
wantHTTP: http.StatusServiceUnavailable,
|
||||
wantMessage: "Card delivery could not be confirmed",
|
||||
wantCalls: []string{"prepare current", "deliver current"},
|
||||
wantLockSequence: 1,
|
||||
wantCardWell: "Card jammed",
|
||||
},
|
||||
{
|
||||
name: "failed next-card dispatch does not undo delivered card",
|
||||
dispenser: fakeDoorCardDispenser{
|
||||
beginNextErr: errors.New("dispatch failed"),
|
||||
},
|
||||
wantHTTP: http.StatusOK,
|
||||
wantMessage: "Card issued successfully",
|
||||
wantCalls: []string{"prepare current", "deliver current", "begin prepare next"},
|
||||
wantLockSequence: 1,
|
||||
},
|
||||
{
|
||||
name: "encoding failure with safe recovery remains retryable",
|
||||
lockErr: encodingErr,
|
||||
wantHTTP: http.StatusBadGateway,
|
||||
wantMessage: encodingErr.Error(),
|
||||
wantCalls: []string{"prepare current", "deliver current", "prepare next"},
|
||||
wantLockSequence: 1,
|
||||
},
|
||||
{
|
||||
name: "encoding failure with failed-card delivery failure is unavailable",
|
||||
dispenser: fakeDoorCardDispenser{
|
||||
deliverCurrent: dispenserCallResult{status: "Card jammed", err: errors.New("delivery jammed")},
|
||||
},
|
||||
lockErr: encodingErr,
|
||||
wantHTTP: http.StatusServiceUnavailable,
|
||||
wantMessage: "Dispenser recovery failed; another encoding attempt is not safe",
|
||||
wantCalls: []string{"prepare current", "deliver current"},
|
||||
wantLockSequence: 1,
|
||||
wantCardWell: "Card jammed",
|
||||
},
|
||||
{
|
||||
name: "encoding failure with empty card well has stable message",
|
||||
dispenser: fakeDoorCardDispenser{
|
||||
prepareNext: dispenserCallResult{status: "Card empty", err: dispenser.ErrCardWellEmpty},
|
||||
},
|
||||
lockErr: encodingErr,
|
||||
wantHTTP: http.StatusServiceUnavailable,
|
||||
wantMessage: dispenser.CardWellEmptyMessage,
|
||||
wantCalls: []string{"prepare current", "deliver current", "prepare next"},
|
||||
wantLockSequence: 1,
|
||||
wantCardWell: "Card empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dispenser := test.dispenser
|
||||
lock := &fakeDoorCardLockServer{sequenceErr: test.lockErr}
|
||||
|
||||
recorder, response, app := performIssueDoorCardRequest(t, &dispenser, lock)
|
||||
|
||||
if recorder.Code != test.wantHTTP {
|
||||
t.Fatalf("HTTP status = %d, want %d", recorder.Code, test.wantHTTP)
|
||||
}
|
||||
if response.Code != test.wantHTTP {
|
||||
t.Fatalf("response code = %d, want %d", response.Code, test.wantHTTP)
|
||||
}
|
||||
if response.Message != test.wantMessage {
|
||||
t.Fatalf("response message = %q, want %q", response.Message, test.wantMessage)
|
||||
}
|
||||
if !reflect.DeepEqual(dispenser.calls, test.wantCalls) {
|
||||
t.Fatalf("dispenser calls = %#v, want %#v", dispenser.calls, test.wantCalls)
|
||||
}
|
||||
if lock.sequenceCalls != test.wantLockSequence {
|
||||
t.Fatalf("lock sequence calls = %d, want %d", lock.sequenceCalls, test.wantLockSequence)
|
||||
}
|
||||
if test.wantLockSequence > 0 && lock.buildCalls != 1 {
|
||||
t.Fatalf("build command calls = %d, want 1", lock.buildCalls)
|
||||
}
|
||||
if test.wantLockSequence == 0 && lock.buildCalls != 0 {
|
||||
t.Fatalf("build command calls = %d, want 0", lock.buildCalls)
|
||||
}
|
||||
if got := app.CardWellStatus(); got != test.wantCardWell {
|
||||
t.Fatalf("card-well status = %q, want %q", got, test.wantCardWell)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type responseTrackingRecorder struct {
|
||||
*httptest.ResponseRecorder
|
||||
wroteResponse bool
|
||||
}
|
||||
|
||||
func (r *responseTrackingRecorder) WriteHeader(statusCode int) {
|
||||
r.wroteResponse = true
|
||||
r.ResponseRecorder.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (r *responseTrackingRecorder) Write(body []byte) (int, error) {
|
||||
r.wroteResponse = true
|
||||
return r.ResponseRecorder.Write(body)
|
||||
}
|
||||
|
||||
func TestIssueDoorCardDispatchesNextCardBeforeHTTP200(t *testing.T) {
|
||||
var recorder *responseTrackingRecorder
|
||||
dispatchCalled := false
|
||||
dispenser := &fakeDoorCardDispenser{
|
||||
beginNext: func() error {
|
||||
dispatchCalled = true
|
||||
if recorder.wroteResponse {
|
||||
t.Error("HTTP response started before next-card preparation was dispatched")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
lock := &fakeDoorCardLockServer{}
|
||||
payload := lockserver.DoorCardRequest{
|
||||
RoomField: "101",
|
||||
CheckinTime: "2026-07-23 15:00:00 +0100",
|
||||
CheckoutTime: "2026-07-24 11:00:00 +0100",
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := &App{disp: dispenser, lockserver: lock, cfg: &config.ConfigRec{}}
|
||||
request := httptest.NewRequest(http.MethodPost, "/issuedoorcard", bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder = &responseTrackingRecorder{ResponseRecorder: httptest.NewRecorder()}
|
||||
|
||||
app.issueDoorCard(recorder, request)
|
||||
|
||||
if !dispatchCalled {
|
||||
t.Fatal("next-card preparation was not dispatched")
|
||||
}
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("HTTP status = %d, want %d", recorder.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueDoorCardLogsNextCardDispatchFailureAndStillSucceeds(t *testing.T) {
|
||||
var logOutput bytes.Buffer
|
||||
standardLogger := log.StandardLogger()
|
||||
previousOutput := standardLogger.Out
|
||||
standardLogger.SetOutput(&logOutput)
|
||||
t.Cleanup(func() {
|
||||
standardLogger.SetOutput(previousOutput)
|
||||
})
|
||||
|
||||
dispenser := &fakeDoorCardDispenser{beginNextErr: errors.New("dispatch failed")}
|
||||
lock := &fakeDoorCardLockServer{}
|
||||
recorder, _, _ := performIssueDoorCardRequest(t, dispenser, lock)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("HTTP status = %d, want %d", recorder.Code, http.StatusOK)
|
||||
}
|
||||
logged := logOutput.String()
|
||||
if !strings.Contains(logged, "dispatch failed") ||
|
||||
!strings.Contains(logged, "Next card preparation dispatch") {
|
||||
t.Fatalf("dispatch failure log = %q", logged)
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,8 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@ -25,8 +27,15 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type doorCardDispenser interface {
|
||||
PrepareCurrentCard(context.Context) (string, error)
|
||||
DeliverCurrentCard(context.Context) (string, error)
|
||||
BeginPrepareNextCard(context.Context) error
|
||||
PrepareNextCard(context.Context) (string, error)
|
||||
}
|
||||
|
||||
type App struct {
|
||||
disp *dispenser.Client
|
||||
disp doorCardDispenser
|
||||
lockserver lockserver.LockServer
|
||||
paymentService *paymentsvc.Service
|
||||
isPayment bool
|
||||
@ -376,47 +385,69 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure dispenser ready (card at encoder) BEFORE we attempt encoding.
|
||||
// With queued dispenser ops, this will not clash with polling.
|
||||
status, err := app.disp.DispenserStart(r.Context())
|
||||
status, err := app.disp.PrepareCurrentCard(r.Context())
|
||||
app.SetCardWellStatus(status)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Dispense error", string(op), "", "", 0)
|
||||
if errors.Is(err, dispenser.ErrCardWellEmpty) {
|
||||
errorhandlers.WriteError(w, http.StatusServiceUnavailable, dispenser.CardWellEmptyMessage)
|
||||
return
|
||||
}
|
||||
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Always attempt to finalize after we have moved a card / started an issuance flow.
|
||||
// This guarantees we eject and prepare the next card even on lock failures.
|
||||
finalize := func() {
|
||||
if app.disp == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
status, ferr := app.disp.DispenserFinal(ctx)
|
||||
if ferr != nil {
|
||||
logging.Error(types.ServiceName, ferr.Error(), "Dispenser final error", string(op), "", "", 0)
|
||||
return
|
||||
}
|
||||
app.SetCardWellStatus(status)
|
||||
}
|
||||
|
||||
// doorReq.RoomField = "104"
|
||||
// build lock server command
|
||||
app.lockserver.BuildCommand(doorReq, checkIn, checkOut)
|
||||
|
||||
// lock server sequence
|
||||
if err := app.lockserver.LockSequence(); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Key encoding", string(op), "", "", 0)
|
||||
finalize()
|
||||
errorhandlers.WriteError(w, http.StatusBadGateway, err.Error())
|
||||
encodingErr := app.lockserver.LockSequence()
|
||||
if encodingErr != nil {
|
||||
logging.Error(types.ServiceName, encodingErr.Error(), "Key encoding", string(op), "", "", 0)
|
||||
}
|
||||
|
||||
// Once encoding has started, finish the physical dispenser sequence even if
|
||||
// the HTTP client disconnects. Finalization remains bounded by one shared timeout.
|
||||
finalizeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
status, deliveryErr := app.disp.DeliverCurrentCard(finalizeCtx)
|
||||
app.SetCardWellStatus(status)
|
||||
if deliveryErr != nil {
|
||||
if encodingErr != nil {
|
||||
recoveryErr := fmt.Errorf("key encoding failed: %v; dispenser recovery failed: %w", encodingErr, deliveryErr)
|
||||
logging.Error(types.ServiceName, recoveryErr.Error(), "Dispenser recovery", string(op), "", "", 0)
|
||||
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispenser recovery failed; another encoding attempt is not safe")
|
||||
return
|
||||
}
|
||||
|
||||
// final dispenser steps
|
||||
finalize()
|
||||
logging.Error(types.ServiceName, deliveryErr.Error(), "Card delivery", string(op), "", "", 0)
|
||||
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Card delivery could not be confirmed")
|
||||
return
|
||||
}
|
||||
|
||||
if encodingErr != nil {
|
||||
status, preparationErr := app.disp.PrepareNextCard(finalizeCtx)
|
||||
app.SetCardWellStatus(status)
|
||||
if preparationErr != nil {
|
||||
recoveryErr := fmt.Errorf("key encoding failed: %v; dispenser recovery failed: %w", encodingErr, preparationErr)
|
||||
logging.Error(types.ServiceName, recoveryErr.Error(), "Dispenser recovery", string(op), "", "", 0)
|
||||
if errors.Is(preparationErr, dispenser.ErrCardWellEmpty) {
|
||||
errorhandlers.WriteError(w, http.StatusServiceUnavailable, dispenser.CardWellEmptyMessage)
|
||||
return
|
||||
}
|
||||
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispenser recovery failed; another encoding attempt is not safe")
|
||||
return
|
||||
}
|
||||
|
||||
errorhandlers.WriteError(w, http.StatusBadGateway, encodingErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if preparationErr := app.disp.BeginPrepareNextCard(finalizeCtx); preparationErr != nil {
|
||||
logging.Error(types.ServiceName, preparationErr.Error(), "Next card preparation dispatch", string(op), "", "", 0)
|
||||
}
|
||||
|
||||
theResponse.Code = http.StatusOK
|
||||
theResponse.Message = "Card issued successfully"
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
@ -28,9 +29,16 @@ type SalePaymentRequest struct {
|
||||
Currency string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type paymentStreamMessage struct {
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Response *cmstypes.ResponseRec `json:"response,omitempty"`
|
||||
}
|
||||
|
||||
func (app *App) salePayment(w http.ResponseWriter, r *http.Request) {
|
||||
const op = logging.Op("salePayment")
|
||||
var response = cmstypes.ResponseRec{
|
||||
|
||||
response := cmstypes.ResponseRec{
|
||||
Status: cmstypes.StatusRec{
|
||||
Code: http.StatusInternalServerError,
|
||||
Message: http.StatusText(http.StatusInternalServerError),
|
||||
@ -49,22 +57,40 @@ func (app *App) salePayment(w http.ResponseWriter, r *http.Request) {
|
||||
writeTransactionResult(w, http.StatusMethodNotAllowed, response)
|
||||
return
|
||||
}
|
||||
|
||||
if app.paymentService == nil {
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Service Not Configured", "Payment service is not configured; cannot process payment requests")
|
||||
mail.SendEmailOnError(
|
||||
app.cfg.Hotel,
|
||||
app.cfg.Kiosk,
|
||||
"Payment Service Not Configured",
|
||||
"Payment service is not configured; cannot process payment requests",
|
||||
)
|
||||
|
||||
response.Data = buildPaymentFailureURL(types.ResultError, "Payment service is not configured")
|
||||
writeTransactionResult(w, http.StatusInternalServerError, response)
|
||||
return
|
||||
}
|
||||
|
||||
if ct := r.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "application/json") {
|
||||
response.Data = buildPaymentFailureURL(types.ResultError, "Content-Type must be application/json")
|
||||
writeTransactionResult(w, http.StatusUnsupportedMediaType, response)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
var req SalePaymentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "ReadJSON", string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
|
||||
logging.Error(
|
||||
types.ServiceName,
|
||||
err.Error(),
|
||||
"ReadJSON",
|
||||
string(op),
|
||||
"",
|
||||
app.cfg.Hotel,
|
||||
app.cfg.Kiosk,
|
||||
)
|
||||
|
||||
response.Data = buildPaymentFailureURL(types.ResultError, "invalid JSON payload: "+err.Error())
|
||||
writeTransactionResult(w, http.StatusBadRequest, response)
|
||||
return
|
||||
@ -75,31 +101,90 @@ func (app *App) salePayment(w http.ResponseWriter, r *http.Request) {
|
||||
writeTransactionResult(w, http.StatusBadRequest, response)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Currency == "" {
|
||||
req.Currency = "GBP"
|
||||
}
|
||||
|
||||
if req.Reference == "" {
|
||||
req.Reference = req.ConfirmNo
|
||||
}
|
||||
|
||||
if req.Reference == "" {
|
||||
req.Reference = uuid.NewString()
|
||||
}
|
||||
|
||||
requestID := buildPaymentRequestID(req.Reference)
|
||||
|
||||
timeoutSeconds := app.cfg.TimeoutSeconds
|
||||
if timeoutSeconds <= 0 {
|
||||
timeoutSeconds = 300
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeoutSeconds)*time.Second)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
r.Context(),
|
||||
time.Duration(timeoutSeconds)*time.Second,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
result, err := app.paymentService.Sale(ctx, paymentsvc.SaleRequest{
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
response.Data = buildPaymentFailureURL(
|
||||
types.ResultError,
|
||||
"Streaming payment updates are not supported",
|
||||
)
|
||||
writeTransactionResult(w, http.StatusInternalServerError, response)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
var streamMu sync.Mutex
|
||||
streamStarted := false
|
||||
streamFailed := false
|
||||
|
||||
sendStreamMessage := func(message paymentStreamMessage) {
|
||||
streamMu.Lock()
|
||||
defer streamMu.Unlock()
|
||||
|
||||
if streamFailed {
|
||||
return
|
||||
}
|
||||
|
||||
if err := encoder.Encode(message); err != nil {
|
||||
streamFailed = true
|
||||
return
|
||||
}
|
||||
|
||||
streamStarted = true
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
onStatus := func(update paymentsvc.StatusUpdate) {
|
||||
if update.Code == "" {
|
||||
return
|
||||
}
|
||||
|
||||
sendStreamMessage(paymentStreamMessage{
|
||||
Type: "status",
|
||||
Code: update.Code,
|
||||
})
|
||||
}
|
||||
|
||||
result, err := app.paymentService.Sale(
|
||||
ctx,
|
||||
paymentsvc.SaleRequest{
|
||||
RequestID: requestID,
|
||||
Reference: req.Reference,
|
||||
Amount: req.Amount,
|
||||
Currency: req.Currency,
|
||||
})
|
||||
},
|
||||
onStatus,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
status := http.StatusBadGateway
|
||||
@ -107,31 +192,66 @@ func (app *App) salePayment(w http.ResponseWriter, r *http.Request) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
|
||||
logging.Error(types.ServiceName, err.Error(), "Payment provider error", string(op), req.Reference, app.cfg.Hotel, app.cfg.Kiosk)
|
||||
logging.Error(
|
||||
types.ServiceName,
|
||||
err.Error(),
|
||||
"Payment provider error",
|
||||
string(op),
|
||||
req.Reference,
|
||||
app.cfg.Hotel,
|
||||
app.cfg.Kiosk,
|
||||
)
|
||||
|
||||
response.Status.Code = status
|
||||
response.Status.Message = http.StatusText(status)
|
||||
response.Data = buildPaymentFailureURL(types.ResultError, err.Error())
|
||||
|
||||
if !streamStarted {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
writeTransactionResult(w, status, response)
|
||||
return
|
||||
}
|
||||
|
||||
sendStreamMessage(paymentStreamMessage{
|
||||
Type: "result",
|
||||
Response: &response,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
response.Status.Code = http.StatusBadGateway
|
||||
response.Status.Message = "Empty payment result"
|
||||
response.Data = buildPaymentFailureURL(types.ResultError, "Payment provider returned an empty result")
|
||||
response.Data = buildPaymentFailureURL(
|
||||
types.ResultError,
|
||||
"Payment provider returned an empty result",
|
||||
)
|
||||
|
||||
if !streamStarted {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
writeTransactionResult(w, http.StatusBadGateway, response)
|
||||
return
|
||||
}
|
||||
|
||||
sendStreamMessage(paymentStreamMessage{
|
||||
Type: "result",
|
||||
Response: &response,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
response.Status.Code = http.StatusOK
|
||||
|
||||
if result.Success && strings.EqualFold(result.Status, "APPROVED") {
|
||||
printer.PrintSaleReceipt(result.CustomerReceipt)
|
||||
response.Status.Code = http.StatusOK
|
||||
|
||||
response.Status.Message = result.Message
|
||||
response.Data = buildPaymentSuccessURL(result)
|
||||
writeTransactionResult(w, http.StatusOK, response)
|
||||
|
||||
sendStreamMessage(paymentStreamMessage{
|
||||
Type: "result",
|
||||
Response: &response,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@ -145,9 +265,14 @@ func (app *App) salePayment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Printf("Transaction failed: %s", description)
|
||||
printer.PrintSaleReceipt(result.CustomerReceipt)
|
||||
|
||||
response.Status.Message = "Payment unsuccessful"
|
||||
response.Data = buildPaymentFailureURL(types.ResultError, description)
|
||||
writeTransactionResult(w, http.StatusOK, response)
|
||||
|
||||
sendStreamMessage(paymentStreamMessage{
|
||||
Type: "result",
|
||||
Response: &response,
|
||||
})
|
||||
}
|
||||
|
||||
func buildPaymentRequestID(reference string) string {
|
||||
@ -173,16 +298,16 @@ func setPaymentCORS(w http.ResponseWriter) {
|
||||
}
|
||||
|
||||
func buildPaymentSuccessURL(result *paymentsvc.Result) string {
|
||||
txnReference := result.ReferenceNumber
|
||||
if txnReference == "" {
|
||||
txnReference = result.TransactionID
|
||||
}
|
||||
// txnReference := result.ReferenceNumber
|
||||
// if txnReference == "" {
|
||||
// txnReference = result.TransactionID
|
||||
// }
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("CardNumber", hex.EncodeToString([]byte(result.CardNumber)))
|
||||
q.Set("CardType", hex.EncodeToString([]byte(result.CardType)))
|
||||
q.Set("ExpiryDate", hex.EncodeToString([]byte(result.ExpiryDate)))
|
||||
q.Set("TxnReference", txnReference)
|
||||
q.Set("TxnReference", result.RequestID)
|
||||
q.Set("CardHash", hex.EncodeToString([]byte(result.CardHash)))
|
||||
q.Set("CardReference", hex.EncodeToString([]byte(result.CardReference)))
|
||||
|
||||
|
||||
162
internal/handlers/payment_handlers_test.go
Normal file
162
internal/handlers/payment_handlers_test.go
Normal file
@ -0,0 +1,162 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/config"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
|
||||
)
|
||||
|
||||
type paymentProviderFunc func(
|
||||
context.Context,
|
||||
paymentsvc.SaleRequest,
|
||||
paymentsvc.StatusHandler,
|
||||
) (*paymentsvc.Result, error)
|
||||
|
||||
func (f paymentProviderFunc) Sale(
|
||||
ctx context.Context,
|
||||
request paymentsvc.SaleRequest,
|
||||
onStatus paymentsvc.StatusHandler,
|
||||
) (*paymentsvc.Result, error) {
|
||||
return f(ctx, request, onStatus)
|
||||
}
|
||||
|
||||
func TestSalePaymentStreamsTerminalUnavailableAsAuthoritativeUnsuccessfulResult(t *testing.T) {
|
||||
provider := paymentProviderFunc(func(
|
||||
_ context.Context,
|
||||
request paymentsvc.SaleRequest,
|
||||
onStatus paymentsvc.StatusHandler,
|
||||
) (*paymentsvc.Result, error) {
|
||||
onStatus(paymentsvc.StatusUpdate{Code: paymentstatus.Starting})
|
||||
onStatus(paymentsvc.StatusUpdate{Code: paymentstatus.TerminalUnavailable})
|
||||
return &paymentsvc.Result{
|
||||
RequestID: request.RequestID,
|
||||
Operation: "SALE",
|
||||
Status: "TERMINAL_UNAVAILABLE",
|
||||
ErrorMessage: "Payment terminal is unavailable",
|
||||
Amount: request.Amount,
|
||||
Currency: request.Currency,
|
||||
DeviceUsed: "terminal-1",
|
||||
DeviceType: "Dojo Terminal",
|
||||
}, nil
|
||||
})
|
||||
|
||||
recorder := performSalePaymentRequest(t, provider)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("outer status = %d, want %d", recorder.Code, http.StatusOK)
|
||||
}
|
||||
messages := decodePaymentStream(t, recorder.Body)
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("stream messages = %#v, want two statuses and one result", messages)
|
||||
}
|
||||
if messages[0].Type != "status" || messages[0].Code != paymentstatus.Starting ||
|
||||
messages[1].Type != "status" || messages[1].Code != paymentstatus.TerminalUnavailable {
|
||||
t.Fatalf("status frames = %#v", messages[:2])
|
||||
}
|
||||
final := messages[2]
|
||||
if final.Type != "result" || final.Response == nil {
|
||||
t.Fatalf("final frame = %#v", final)
|
||||
}
|
||||
if final.Response.Status.Code != http.StatusOK {
|
||||
t.Fatalf("nested status = %d, want %d", final.Response.Status.Code, http.StatusOK)
|
||||
}
|
||||
resultURL, err := url.Parse(final.Response.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resultURL.Path != types.CheckinUnsuccessfulEndpoint {
|
||||
t.Fatalf("result path = %q, want %q", resultURL.Path, types.CheckinUnsuccessfulEndpoint)
|
||||
}
|
||||
if got := resultURL.Query().Get("Description"); got != "Payment terminal is unavailable" {
|
||||
t.Fatalf("Description = %q", got)
|
||||
}
|
||||
|
||||
body := recorder.Body.String()
|
||||
for _, forbidden := range []string{
|
||||
"Dojo returned HTTP 409",
|
||||
"offline or currently in use",
|
||||
"traceId",
|
||||
"docs.dojo.tech",
|
||||
} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("stream exposed %q: %s", forbidden, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSalePaymentRetainsNestedBadGatewayForProviderErrorsAfterStreamingStarts(t *testing.T) {
|
||||
provider := paymentProviderFunc(func(
|
||||
_ context.Context,
|
||||
_ paymentsvc.SaleRequest,
|
||||
onStatus paymentsvc.StatusHandler,
|
||||
) (*paymentsvc.Result, error) {
|
||||
onStatus(paymentsvc.StatusUpdate{Code: paymentstatus.Starting})
|
||||
return nil, errors.New("provider failed")
|
||||
})
|
||||
|
||||
recorder := performSalePaymentRequest(t, provider)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("outer status = %d, want streaming status %d", recorder.Code, http.StatusOK)
|
||||
}
|
||||
messages := decodePaymentStream(t, recorder.Body)
|
||||
if len(messages) != 2 || messages[1].Type != "result" || messages[1].Response == nil {
|
||||
t.Fatalf("stream messages = %#v", messages)
|
||||
}
|
||||
if messages[1].Response.Status.Code != http.StatusBadGateway {
|
||||
t.Fatalf("nested status = %d, want %d", messages[1].Response.Status.Code, http.StatusBadGateway)
|
||||
}
|
||||
resultURL, err := url.Parse(messages[1].Response.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resultURL.Path != types.CheckinUnsuccessfulEndpoint {
|
||||
t.Fatalf("result path = %q, want %q", resultURL.Path, types.CheckinUnsuccessfulEndpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func performSalePaymentRequest(t *testing.T, provider paymentsvc.Provider) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
app := &App{
|
||||
paymentService: paymentsvc.NewService(provider),
|
||||
cfg: &config.ConfigRec{
|
||||
Hotel: "HOTEL",
|
||||
Kiosk: 7,
|
||||
TimeoutSeconds: 5,
|
||||
},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/payment/sale",
|
||||
strings.NewReader(`{"reference":"BOOKING-123","amount":10852,"currency":"GBP"}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
app.salePayment(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func decodePaymentStream(t *testing.T, body io.Reader) []paymentStreamMessage {
|
||||
t.Helper()
|
||||
decoder := json.NewDecoder(body)
|
||||
var messages []paymentStreamMessage
|
||||
for {
|
||||
var message paymentStreamMessage
|
||||
if err := decoder.Decode(&message); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return messages
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
messages = append(messages, message)
|
||||
}
|
||||
}
|
||||
@ -9,10 +9,10 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||
"gitea.futuresens.co.uk/futuresens/logging"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@ -59,7 +59,7 @@ func (app *App) testIssueDoorCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Ensure dispenser ready (card at encoder) BEFORE we attempt encoding.
|
||||
// With queued dispenser ops, this will not clash with polling.
|
||||
status, err := app.disp.DispenserStart(r.Context())
|
||||
status, err := app.disp.PrepareCurrentCard(r.Context())
|
||||
app.SetCardWellStatus(status)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Dispense error", string(op), "", "", 0)
|
||||
|
||||
@ -10,7 +10,9 @@ import (
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
|
||||
"github.com/gorilla/websocket"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
@ -31,7 +33,7 @@ func NewClient(url, apiKey string, timeoutSeconds int) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Sale(ctx context.Context, req paymentsvc.SaleRequest) (*paymentsvc.Result, error) {
|
||||
func (c *Client) Sale(ctx context.Context, req paymentsvc.SaleRequest, onStatus paymentsvc.StatusHandler) (*paymentsvc.Result, error) {
|
||||
if req.Currency == "" {
|
||||
req.Currency = "GBP"
|
||||
}
|
||||
@ -44,10 +46,12 @@ func (c *Client) Sale(ctx context.Context, req paymentsvc.SaleRequest) (*payment
|
||||
TimeoutSeconds: c.TimeoutSeconds,
|
||||
}
|
||||
|
||||
return c.doPayment(ctx, payBridgeReq)
|
||||
return c.doPayment(ctx, payBridgeReq, onStatus)
|
||||
}
|
||||
|
||||
func (c *Client) doPayment(ctx context.Context, req PaymentRequest) (*paymentsvc.Result, error) {
|
||||
func (c *Client) doPayment(ctx context.Context, req PaymentRequest, onStatus paymentsvc.StatusHandler) (*paymentsvc.Result, error) {
|
||||
emitStatus(onStatus, paymentstatus.Starting)
|
||||
|
||||
connectURL, err := c.connectURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -75,6 +79,9 @@ func (c *Client) doPayment(ctx context.Context, req PaymentRequest) (*paymentsvc
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info("PayBridge authentication successful")
|
||||
emitStatus(onStatus, paymentstatus.Started)
|
||||
|
||||
if err := ws.WriteJSON(Envelope{
|
||||
Type: types.MesTypePaymentRequest,
|
||||
JWT: jwt,
|
||||
@ -84,36 +91,109 @@ func (c *Client) doPayment(ctx context.Context, req PaymentRequest) (*paymentsvc
|
||||
return nil, fmt.Errorf("send PayBridge payment_request: %w", err)
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"requestId": req.RequestID,
|
||||
"operation": req.Operation,
|
||||
"amount": req.Amount,
|
||||
"currency": req.Currency,
|
||||
}).Info("PayBridge payment request sent")
|
||||
|
||||
emitStatus(onStatus, paymentstatus.RequestSent)
|
||||
|
||||
for {
|
||||
_, raw, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("read PayBridge message: %w", err)
|
||||
}
|
||||
|
||||
var head struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
return nil, fmt.Errorf("decode PayBridge message header: %w", err)
|
||||
}
|
||||
|
||||
switch strings.ToLower(head.Type) {
|
||||
messageType := strings.ToLower(head.Type)
|
||||
|
||||
switch messageType {
|
||||
case types.MesTypePaymentStatusUpdate:
|
||||
var update StatusUpdateEnvelope
|
||||
if err := json.Unmarshal(raw, &update); err != nil {
|
||||
return nil, fmt.Errorf("decode payment_status_update: %w", err)
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"status": update.Data.Status,
|
||||
"code": update.Data.Code,
|
||||
}).Info("PayBridge status update")
|
||||
|
||||
statusCode, known := mapPayBridgeStatus(
|
||||
update.Data.Status,
|
||||
update.Data.Code,
|
||||
)
|
||||
|
||||
if !known {
|
||||
log.WithFields(log.Fields{
|
||||
"status": update.Data.Status,
|
||||
"code": update.Data.Code,
|
||||
}).Warn("Unknown PayBridge status update")
|
||||
}
|
||||
|
||||
emitStatus(onStatus, statusCode)
|
||||
|
||||
case types.MesTypePaymentAccepted:
|
||||
log.Info("PayBridge payment request accepted")
|
||||
emitStatus(onStatus, paymentstatus.Accepted)
|
||||
|
||||
case types.MesTypePaymentResult:
|
||||
var result PaymentResultEnvelope
|
||||
// log.Println("PayBridge payment result raw:", string(raw))
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return nil, fmt.Errorf("decode payment_result: %w", err)
|
||||
}
|
||||
return mapPaymentResult(result), nil
|
||||
|
||||
mapped := mapPaymentResult(result)
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"requestId": mapped.RequestID,
|
||||
"transactionId": mapped.TransactionID,
|
||||
"status": mapped.Status,
|
||||
"success": mapped.Success,
|
||||
}).Info("PayBridge payment result received")
|
||||
|
||||
emitStatus(
|
||||
onStatus,
|
||||
mapPayBridgeFinalStatus(mapped.Status, mapped.Success),
|
||||
)
|
||||
|
||||
return mapped, nil
|
||||
|
||||
case types.MesTypePaymentError:
|
||||
var paymentErr PaymentErrorEnvelope
|
||||
if err := json.Unmarshal(raw, &paymentErr); err != nil {
|
||||
return nil, fmt.Errorf("decode payment_error: %w", err)
|
||||
}
|
||||
return mapPaymentError(req, paymentErr), nil
|
||||
|
||||
mapped := mapPaymentError(req, paymentErr)
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"requestId": mapped.RequestID,
|
||||
"transactionId": mapped.TransactionID,
|
||||
"status": mapped.Status,
|
||||
"error": mapped.ErrorMessage,
|
||||
}).Warn("PayBridge payment error received")
|
||||
|
||||
emitStatus(
|
||||
onStatus,
|
||||
mapPayBridgeFinalStatus(mapped.Status, false),
|
||||
)
|
||||
|
||||
return mapped, nil
|
||||
|
||||
case types.ResultError:
|
||||
var genericErr struct {
|
||||
@ -122,20 +202,47 @@ func (c *Client) doPayment(ctx context.Context, req PaymentRequest) (*paymentsvc
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &genericErr); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnexpectedMessage, string(raw))
|
||||
}
|
||||
if genericErr.Error.Message != "" {
|
||||
return nil, fmt.Errorf("%w: %s: %s", ErrUnexpectedMessage, genericErr.Error.Code, genericErr.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnexpectedMessage, string(raw))
|
||||
|
||||
case types.MesTypePaymentStatusUpdate, types.MesTypePaymentAccepted, types.MesTypeAuthSuccess:
|
||||
// Intermediate message. Keep waiting for payment_result or payment_error.
|
||||
if err := json.Unmarshal(raw, &genericErr); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: %s",
|
||||
ErrUnexpectedMessage,
|
||||
string(raw),
|
||||
)
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"code": genericErr.Error.Code,
|
||||
"message": genericErr.Error.Message,
|
||||
}).Error("PayBridge error message received")
|
||||
|
||||
emitStatus(onStatus, paymentstatus.Error)
|
||||
|
||||
if genericErr.Error.Message != "" {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: %s: %s",
|
||||
ErrUnexpectedMessage,
|
||||
genericErr.Error.Code,
|
||||
genericErr.Error.Message,
|
||||
)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"%w: %s",
|
||||
ErrUnexpectedMessage,
|
||||
string(raw),
|
||||
)
|
||||
|
||||
case types.MesTypeAuthSuccess:
|
||||
log.Info("Additional PayBridge auth_success message received")
|
||||
|
||||
default:
|
||||
// PayBridge may introduce additional intermediate message types.
|
||||
// Ignore them and continue waiting for the final result.
|
||||
log.WithField(
|
||||
"messageType",
|
||||
head.Type,
|
||||
).Warn("Unknown PayBridge intermediate message type")
|
||||
|
||||
emitStatus(onStatus, paymentstatus.Processing)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -143,7 +250,10 @@ func (c *Client) doPayment(ctx context.Context, req PaymentRequest) (*paymentsvc
|
||||
func (c *Client) connectURL() (string, error) {
|
||||
u, err := url.Parse(c.URL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse PayBridge WebSocket URL: %w", err)
|
||||
return "", fmt.Errorf(
|
||||
"parse PayBridge WebSocket URL: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
@ -154,29 +264,186 @@ func (c *Client) connectURL() (string, error) {
|
||||
}
|
||||
|
||||
func (c *Client) readAuthSuccess(ws *websocket.Conn) (string, error) {
|
||||
if err := ws.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
||||
return "", fmt.Errorf("%w: set auth read deadline: %v", ErrAuthFailed, err)
|
||||
if err := ws.SetReadDeadline(
|
||||
time.Now().Add(10 * time.Second),
|
||||
); err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"%w: set auth read deadline: %v",
|
||||
ErrAuthFailed,
|
||||
err,
|
||||
)
|
||||
}
|
||||
defer ws.SetReadDeadline(time.Time{})
|
||||
|
||||
_, raw, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: read auth_success: %v", ErrAuthFailed, err)
|
||||
return "", fmt.Errorf(
|
||||
"%w: read auth_success: %v",
|
||||
ErrAuthFailed,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
var auth struct {
|
||||
Type string `json:"type"`
|
||||
JWT string `json:"jwt"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(raw, &auth); err != nil {
|
||||
return "", fmt.Errorf("%w: decode auth_success: %v", ErrAuthFailed, err)
|
||||
return "", fmt.Errorf(
|
||||
"%w: decode auth_success: %v",
|
||||
ErrAuthFailed,
|
||||
err,
|
||||
)
|
||||
}
|
||||
if !strings.EqualFold(auth.Type, types.MesTypeAuthSuccess) {
|
||||
return "", fmt.Errorf("%w: expected auth_success, got %s: %s", ErrAuthFailed, auth.Type, string(raw))
|
||||
|
||||
if !strings.EqualFold(
|
||||
auth.Type,
|
||||
types.MesTypeAuthSuccess,
|
||||
) {
|
||||
return "", fmt.Errorf(
|
||||
"%w: expected auth_success, got %s: %s",
|
||||
ErrAuthFailed,
|
||||
auth.Type,
|
||||
string(raw),
|
||||
)
|
||||
}
|
||||
|
||||
if auth.JWT == "" {
|
||||
return "", fmt.Errorf("%w: auth_success did not contain jwt", ErrAuthFailed)
|
||||
return "", fmt.Errorf(
|
||||
"%w: auth_success did not contain jwt",
|
||||
ErrAuthFailed,
|
||||
)
|
||||
}
|
||||
|
||||
return auth.JWT, nil
|
||||
}
|
||||
|
||||
func emitStatus(
|
||||
onStatus paymentsvc.StatusHandler,
|
||||
code string,
|
||||
) {
|
||||
if onStatus == nil || code == "" {
|
||||
return
|
||||
}
|
||||
|
||||
onStatus(paymentsvc.StatusUpdate{
|
||||
Code: code,
|
||||
})
|
||||
}
|
||||
|
||||
func mapPayBridgeStatus(
|
||||
status string,
|
||||
code string,
|
||||
) (string, bool) {
|
||||
normalizedStatus := strings.ToLower(
|
||||
strings.TrimSpace(status),
|
||||
)
|
||||
|
||||
switch normalizedStatus {
|
||||
case payBridgeMessageInsertOrSwipeCard,
|
||||
payBridgeMessageInsertSwipeOrPresentCard:
|
||||
return paymentstatus.PresentCard, true
|
||||
|
||||
case payBridgeMessageInsertCard:
|
||||
return paymentstatus.InsertCard, true
|
||||
|
||||
case payBridgeMessagePleaseWait:
|
||||
return paymentstatus.PleaseWait, true
|
||||
|
||||
case payBridgeMessageDoNotRemoveCard:
|
||||
return paymentstatus.DoNotRemoveCard, true
|
||||
|
||||
case payBridgeMessageTryAnotherInterface:
|
||||
return paymentstatus.TryAnotherInterface, true
|
||||
|
||||
case payBridgeMessagePIN:
|
||||
return paymentstatus.EnterPIN, true
|
||||
|
||||
case payBridgeMessagePINAgain:
|
||||
return paymentstatus.EnterPINAgain, true
|
||||
|
||||
case payBridgeMessageTransactionCancelled:
|
||||
return paymentstatus.Cancelled, true
|
||||
|
||||
case payBridgeMessageProcessing:
|
||||
return paymentstatus.Processing, true
|
||||
|
||||
case payBridgeMessageApproved:
|
||||
// Это ещё промежуточный статус.
|
||||
// Финальный успех определяется только по payment_result.
|
||||
return paymentstatus.Authorized, true
|
||||
|
||||
case payBridgeMessageDeclined:
|
||||
return paymentstatus.Declined, true
|
||||
}
|
||||
|
||||
// Fallback по фактически обнаруженным кодам терминала.
|
||||
switch strings.TrimSpace(code) {
|
||||
case payBridgeCodeInsertCard:
|
||||
return paymentstatus.InsertCard, true
|
||||
|
||||
case payBridgeCodeEnterPIN:
|
||||
return paymentstatus.EnterPIN, true
|
||||
|
||||
case payBridgeCodeCancelled:
|
||||
return paymentstatus.Cancelled, true
|
||||
|
||||
case payBridgeCodePleaseWait:
|
||||
return paymentstatus.PleaseWait, true
|
||||
|
||||
case payBridgeCodePresentCard, payBridgeCodePresentCardAlternate:
|
||||
return paymentstatus.PresentCard, true
|
||||
|
||||
case payBridgeCodeEnterPINAgain:
|
||||
return paymentstatus.EnterPINAgain, true
|
||||
|
||||
case payBridgeCodeTryAnotherInterface:
|
||||
return paymentstatus.TryAnotherInterface, true
|
||||
|
||||
case payBridgeCodeDoNotRemoveCard:
|
||||
return paymentstatus.DoNotRemoveCard, true
|
||||
}
|
||||
|
||||
// Пока назначение кодов 200, 201, 205 и 210 неизвестно.
|
||||
// Они останутся в логах, но на экране будет общий статус.
|
||||
return paymentstatus.Processing, false
|
||||
}
|
||||
|
||||
func mapPayBridgeFinalStatus(
|
||||
status string,
|
||||
success bool,
|
||||
) string {
|
||||
if success && strings.EqualFold(status, payBridgeFinalStatusApproved) {
|
||||
return paymentstatus.Approved
|
||||
}
|
||||
|
||||
switch strings.ToUpper(status) {
|
||||
case payBridgeFinalStatusDeclined:
|
||||
return paymentstatus.Declined
|
||||
|
||||
case payBridgeFinalStatusCancelled:
|
||||
return paymentstatus.Cancelled
|
||||
|
||||
case payBridgeFinalStatusTimeout:
|
||||
return paymentstatus.Timeout
|
||||
|
||||
case payBridgeFinalStatusVoided:
|
||||
return paymentstatus.Voided
|
||||
|
||||
case payBridgeFinalStatusVoidedDailyLimitExceeded:
|
||||
return paymentstatus.DailyLimitExceeded
|
||||
|
||||
case payBridgeFinalStatusDailyLimitVoidFailed:
|
||||
return paymentstatus.VoidFailed
|
||||
|
||||
case payBridgeFinalStatusDailyLimitValidationError:
|
||||
return paymentstatus.LimitValidationError
|
||||
|
||||
case payBridgeFinalStatusError, payBridgeFinalStatusFailed:
|
||||
return paymentstatus.Error
|
||||
|
||||
default:
|
||||
return paymentstatus.Error
|
||||
}
|
||||
}
|
||||
|
||||
41
internal/paybridge/statuses.go
Normal file
41
internal/paybridge/statuses.go
Normal file
@ -0,0 +1,41 @@
|
||||
package paybridge
|
||||
|
||||
const (
|
||||
payBridgeFinalStatusApproved = "APPROVED"
|
||||
payBridgeFinalStatusDeclined = "DECLINED"
|
||||
payBridgeFinalStatusCancelled = "CANCELLED"
|
||||
payBridgeFinalStatusTimeout = "TIMEOUT"
|
||||
payBridgeFinalStatusVoided = "VOIDED"
|
||||
payBridgeFinalStatusVoidedDailyLimitExceeded = "VOIDED_DAILY_LIMIT_EXCEEDED"
|
||||
payBridgeFinalStatusDailyLimitVoidFailed = "DAILY_LIMIT_EXCEEDED_VOID_FAILED"
|
||||
payBridgeFinalStatusDailyLimitValidationError = "DAILY_LIMIT_VALIDATION_ERROR"
|
||||
payBridgeFinalStatusError = "ERROR"
|
||||
payBridgeFinalStatusFailed = "FAILED"
|
||||
)
|
||||
|
||||
const (
|
||||
payBridgeMessageInsertOrSwipeCard = "insert or swipe card"
|
||||
payBridgeMessageInsertSwipeOrPresentCard = "insert, swipe or present card"
|
||||
payBridgeMessageInsertCard = "insert card"
|
||||
payBridgeMessagePleaseWait = "please wait"
|
||||
payBridgeMessageDoNotRemoveCard = "please wait. do not remove card"
|
||||
payBridgeMessageTryAnotherInterface = "please try another interface"
|
||||
payBridgeMessagePIN = "pin"
|
||||
payBridgeMessagePINAgain = "pin again"
|
||||
payBridgeMessageTransactionCancelled = "transaction cancelled"
|
||||
payBridgeMessageProcessing = "processing"
|
||||
payBridgeMessageApproved = "approved"
|
||||
payBridgeMessageDeclined = "declined"
|
||||
)
|
||||
|
||||
const (
|
||||
payBridgeCodeInsertCard = "101"
|
||||
payBridgeCodeEnterPIN = "106"
|
||||
payBridgeCodeCancelled = "124"
|
||||
payBridgeCodePleaseWait = "1017"
|
||||
payBridgeCodePresentCard = "1109"
|
||||
payBridgeCodePresentCardAlternate = "1113"
|
||||
payBridgeCodeEnterPINAgain = "1129"
|
||||
payBridgeCodeTryAnotherInterface = "1274"
|
||||
payBridgeCodeDoNotRemoveCard = "1312"
|
||||
)
|
||||
125
internal/paybridge/statuses_test.go
Normal file
125
internal/paybridge/statuses_test.go
Normal file
@ -0,0 +1,125 @@
|
||||
package paybridge
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
|
||||
)
|
||||
|
||||
func TestMapPayBridgeFinalStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status string
|
||||
success bool
|
||||
want string
|
||||
}{
|
||||
{"approved success", "APPROVED", true, paymentstatus.Approved},
|
||||
{"approved without success", "APPROVED", false, paymentstatus.Error},
|
||||
{"declined", "DECLINED", false, paymentstatus.Declined},
|
||||
{"cancelled", "CANCELLED", false, paymentstatus.Cancelled},
|
||||
{"timeout", "TIMEOUT", false, paymentstatus.Timeout},
|
||||
{"voided", "VOIDED", false, paymentstatus.Voided},
|
||||
{"voided daily limit exceeded", "VOIDED_DAILY_LIMIT_EXCEEDED", false, paymentstatus.DailyLimitExceeded},
|
||||
{"daily limit void failed", "DAILY_LIMIT_EXCEEDED_VOID_FAILED", false, paymentstatus.VoidFailed},
|
||||
{"daily limit validation error", "DAILY_LIMIT_VALIDATION_ERROR", false, paymentstatus.LimitValidationError},
|
||||
{"error", "ERROR", false, paymentstatus.Error},
|
||||
{"failed", "FAILED", false, paymentstatus.Error},
|
||||
{"unknown", "UNKNOWN", false, paymentstatus.Error},
|
||||
{"approved case insensitive", "approved", true, paymentstatus.Approved},
|
||||
{"declined case insensitive", "declined", false, paymentstatus.Declined},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := mapPayBridgeFinalStatus(test.status, test.success)
|
||||
if got != test.want {
|
||||
t.Fatalf("got %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapPayBridgeStatusMessages(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status string
|
||||
want string
|
||||
}{
|
||||
{"insert or swipe card", "insert or swipe card", paymentstatus.PresentCard},
|
||||
{"insert swipe or present card", "insert, swipe or present card", paymentstatus.PresentCard},
|
||||
{"insert card", "insert card", paymentstatus.InsertCard},
|
||||
{"please wait", "please wait", paymentstatus.PleaseWait},
|
||||
{"do not remove card", "please wait. do not remove card", paymentstatus.DoNotRemoveCard},
|
||||
{"try another interface", "please try another interface", paymentstatus.TryAnotherInterface},
|
||||
{"pin", "pin", paymentstatus.EnterPIN},
|
||||
{"pin again", "pin again", paymentstatus.EnterPINAgain},
|
||||
{"transaction cancelled", "transaction cancelled", paymentstatus.Cancelled},
|
||||
{"processing", "processing", paymentstatus.Processing},
|
||||
{"approved", "approved", paymentstatus.Authorized},
|
||||
{"declined", "declined", paymentstatus.Declined},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, matched := mapPayBridgeStatus(test.status, "")
|
||||
if !matched {
|
||||
t.Fatal("expected status to match")
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("got %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapPayBridgeStatusCodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code string
|
||||
want string
|
||||
}{
|
||||
{"insert card", "101", paymentstatus.InsertCard},
|
||||
{"enter pin", "106", paymentstatus.EnterPIN},
|
||||
{"cancelled", "124", paymentstatus.Cancelled},
|
||||
{"please wait", "1017", paymentstatus.PleaseWait},
|
||||
{"present card", "1109", paymentstatus.PresentCard},
|
||||
{"present card alternate", "1113", paymentstatus.PresentCard},
|
||||
{"enter pin again", "1129", paymentstatus.EnterPINAgain},
|
||||
{"try another interface", "1274", paymentstatus.TryAnotherInterface},
|
||||
{"do not remove card", "1312", paymentstatus.DoNotRemoveCard},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, matched := mapPayBridgeStatus("", test.code)
|
||||
if !matched {
|
||||
t.Fatal("expected code to match")
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("got %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapPayBridgeStatusNormalization(t *testing.T) {
|
||||
got, matched := mapPayBridgeStatus(" PiN AgAiN ", "")
|
||||
if !matched || got != paymentstatus.EnterPINAgain {
|
||||
t.Fatalf("normalized message got (%q, %t), want (%q, true)", got, matched, paymentstatus.EnterPINAgain)
|
||||
}
|
||||
|
||||
got, matched = mapPayBridgeStatus("", " 101 ")
|
||||
if !matched || got != paymentstatus.InsertCard {
|
||||
t.Fatalf("trimmed code got (%q, %t), want (%q, true)", got, matched, paymentstatus.InsertCard)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapPayBridgeStatusUnknown(t *testing.T) {
|
||||
got, matched := mapPayBridgeStatus("unknown message", "999")
|
||||
if matched {
|
||||
t.Fatal("unknown status and code must not match")
|
||||
}
|
||||
if got != paymentstatus.Processing {
|
||||
t.Fatalf("got %q, want %q", got, paymentstatus.Processing)
|
||||
}
|
||||
}
|
||||
@ -53,3 +53,13 @@ type PaymentErrorEnvelope struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type StatusUpdateEnvelope struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
} `json:"data"`
|
||||
}
|
||||
@ -6,10 +6,23 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
var ErrPaymentInProgress = errors.New("payment is already in progress")
|
||||
var (
|
||||
ErrPaymentInProgress = errors.New("payment is already in progress")
|
||||
ErrProviderNotConfigured = errors.New("payment provider is not configured")
|
||||
)
|
||||
|
||||
type StatusUpdate struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type StatusHandler func(StatusUpdate)
|
||||
|
||||
type Provider interface {
|
||||
Sale(ctx context.Context, req SaleRequest) (*Result, error)
|
||||
Sale(
|
||||
ctx context.Context,
|
||||
req SaleRequest,
|
||||
onStatus StatusHandler,
|
||||
) (*Result, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@ -20,10 +33,20 @@ type Service struct {
|
||||
}
|
||||
|
||||
func NewService(provider Provider) *Service {
|
||||
return &Service{provider: provider}
|
||||
return &Service{
|
||||
provider: provider,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Sale(
|
||||
ctx context.Context,
|
||||
req SaleRequest,
|
||||
onStatus StatusHandler,
|
||||
) (*Result, error) {
|
||||
if s == nil || s.provider == nil {
|
||||
return nil, ErrProviderNotConfigured
|
||||
}
|
||||
|
||||
func (s *Service) Sale(ctx context.Context, req SaleRequest) (*Result, error) {
|
||||
s.mu.Lock()
|
||||
if s.busy {
|
||||
s.mu.Unlock()
|
||||
@ -38,5 +61,5 @@ func (s *Service) Sale(ctx context.Context, req SaleRequest) (*Result, error) {
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
return s.provider.Sale(ctx, req)
|
||||
return s.provider.Sale(ctx, req, onStatus)
|
||||
}
|
||||
41
paymentstatus/status.go
Normal file
41
paymentstatus/status.go
Normal file
@ -0,0 +1,41 @@
|
||||
// Package paymentstatus defines progress codes emitted by Hardlink's
|
||||
// POST /api/payment/sale stream. These codes are not authoritative final
|
||||
// transaction decisions: clients may continue check-in only after receiving a
|
||||
// final result frame whose response.data begins with /successful.
|
||||
package paymentstatus
|
||||
|
||||
const (
|
||||
Starting = "PAYMENT_STARTING"
|
||||
Started = "PAYMENT_STARTED"
|
||||
RequestSent = "PAYMENT_REQUEST_SENT"
|
||||
Accepted = "PAYMENT_ACCEPTED"
|
||||
PresentCard = "PAYMENT_PRESENT_CARD"
|
||||
InsertCard = "PAYMENT_INSERT_CARD"
|
||||
SwipeCard = "PAYMENT_SWIPE_CARD"
|
||||
EnterPIN = "PAYMENT_ENTER_PIN"
|
||||
EnterPINAgain = "PAYMENT_ENTER_PIN_AGAIN"
|
||||
PleaseWait = "PAYMENT_PLEASE_WAIT"
|
||||
DoNotRemoveCard = "PAYMENT_DO_NOT_REMOVE_CARD"
|
||||
RemoveCard = "PAYMENT_REMOVE_CARD"
|
||||
TryAnotherInterface = "PAYMENT_TRY_ANOTHER_INTERFACE"
|
||||
Processing = "PAYMENT_PROCESSING"
|
||||
Authorized = "PAYMENT_AUTHORIZED"
|
||||
Approved = "PAYMENT_APPROVED"
|
||||
Cancelling = "PAYMENT_CANCELLING"
|
||||
Cancelled = "PAYMENT_CANCELLED"
|
||||
Declined = "PAYMENT_DECLINED"
|
||||
Expired = "PAYMENT_EXPIRED"
|
||||
Timeout = "PAYMENT_TIMEOUT"
|
||||
SignatureRequired = "PAYMENT_SIGNATURE_REQUIRED"
|
||||
SignatureRejecting = "PAYMENT_SIGNATURE_REJECTING"
|
||||
SignatureRejected = "PAYMENT_SIGNATURE_REJECTED"
|
||||
Voided = "PAYMENT_VOIDED"
|
||||
DailyLimitExceeded = "PAYMENT_DAILY_LIMIT_EXCEEDED"
|
||||
VoidFailed = "PAYMENT_VOID_FAILED"
|
||||
LimitValidationError = "PAYMENT_LIMIT_VALIDATION_ERROR"
|
||||
Error = "PAYMENT_ERROR"
|
||||
TerminalUnavailable = "PAYMENT_TERMINAL_UNAVAILABLE"
|
||||
|
||||
DojoNotificationPrefix = "PAYMENT_DOJO_NOTIFICATION_"
|
||||
DojoStatusPrefix = "PAYMENT_DOJO_STATUS_"
|
||||
)
|
||||
84
paymentstatus/status_test.go
Normal file
84
paymentstatus/status_test.go
Normal file
@ -0,0 +1,84 @@
|
||||
package paymentstatus_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
|
||||
)
|
||||
|
||||
func TestFixedStatusValues(t *testing.T) {
|
||||
statuses := []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{"Starting", paymentstatus.Starting, "PAYMENT_STARTING"},
|
||||
{"Started", paymentstatus.Started, "PAYMENT_STARTED"},
|
||||
{"RequestSent", paymentstatus.RequestSent, "PAYMENT_REQUEST_SENT"},
|
||||
{"Accepted", paymentstatus.Accepted, "PAYMENT_ACCEPTED"},
|
||||
{"PresentCard", paymentstatus.PresentCard, "PAYMENT_PRESENT_CARD"},
|
||||
{"InsertCard", paymentstatus.InsertCard, "PAYMENT_INSERT_CARD"},
|
||||
{"SwipeCard", paymentstatus.SwipeCard, "PAYMENT_SWIPE_CARD"},
|
||||
{"EnterPIN", paymentstatus.EnterPIN, "PAYMENT_ENTER_PIN"},
|
||||
{"EnterPINAgain", paymentstatus.EnterPINAgain, "PAYMENT_ENTER_PIN_AGAIN"},
|
||||
{"PleaseWait", paymentstatus.PleaseWait, "PAYMENT_PLEASE_WAIT"},
|
||||
{"DoNotRemoveCard", paymentstatus.DoNotRemoveCard, "PAYMENT_DO_NOT_REMOVE_CARD"},
|
||||
{"RemoveCard", paymentstatus.RemoveCard, "PAYMENT_REMOVE_CARD"},
|
||||
{"TryAnotherInterface", paymentstatus.TryAnotherInterface, "PAYMENT_TRY_ANOTHER_INTERFACE"},
|
||||
{"Processing", paymentstatus.Processing, "PAYMENT_PROCESSING"},
|
||||
{"Authorized", paymentstatus.Authorized, "PAYMENT_AUTHORIZED"},
|
||||
{"Approved", paymentstatus.Approved, "PAYMENT_APPROVED"},
|
||||
{"Cancelling", paymentstatus.Cancelling, "PAYMENT_CANCELLING"},
|
||||
{"Cancelled", paymentstatus.Cancelled, "PAYMENT_CANCELLED"},
|
||||
{"Declined", paymentstatus.Declined, "PAYMENT_DECLINED"},
|
||||
{"Expired", paymentstatus.Expired, "PAYMENT_EXPIRED"},
|
||||
{"Timeout", paymentstatus.Timeout, "PAYMENT_TIMEOUT"},
|
||||
{"SignatureRequired", paymentstatus.SignatureRequired, "PAYMENT_SIGNATURE_REQUIRED"},
|
||||
{"SignatureRejecting", paymentstatus.SignatureRejecting, "PAYMENT_SIGNATURE_REJECTING"},
|
||||
{"SignatureRejected", paymentstatus.SignatureRejected, "PAYMENT_SIGNATURE_REJECTED"},
|
||||
{"Voided", paymentstatus.Voided, "PAYMENT_VOIDED"},
|
||||
{"DailyLimitExceeded", paymentstatus.DailyLimitExceeded, "PAYMENT_DAILY_LIMIT_EXCEEDED"},
|
||||
{"VoidFailed", paymentstatus.VoidFailed, "PAYMENT_VOID_FAILED"},
|
||||
{"LimitValidationError", paymentstatus.LimitValidationError, "PAYMENT_LIMIT_VALIDATION_ERROR"},
|
||||
{"Error", paymentstatus.Error, "PAYMENT_ERROR"},
|
||||
{"TerminalUnavailable", paymentstatus.TerminalUnavailable, "PAYMENT_TERMINAL_UNAVAILABLE"},
|
||||
}
|
||||
|
||||
seen := make(map[string]string, len(statuses))
|
||||
for _, status := range statuses {
|
||||
t.Run(status.name, func(t *testing.T) {
|
||||
if status.value != status.want {
|
||||
t.Fatalf("got %q, want %q", status.value, status.want)
|
||||
}
|
||||
if !strings.HasPrefix(status.value, "PAYMENT_") {
|
||||
t.Fatalf("status %q does not have PAYMENT_ prefix", status.value)
|
||||
}
|
||||
})
|
||||
|
||||
if previous, exists := seen[status.value]; exists {
|
||||
t.Errorf("%s and %s have duplicate value %q", previous, status.name, status.value)
|
||||
}
|
||||
seen[status.value] = status.name
|
||||
}
|
||||
}
|
||||
|
||||
func TestDojoPrefixes(t *testing.T) {
|
||||
if paymentstatus.DojoNotificationPrefix != "PAYMENT_DOJO_NOTIFICATION_" {
|
||||
t.Fatalf(
|
||||
"DojoNotificationPrefix = %q, want %q",
|
||||
paymentstatus.DojoNotificationPrefix,
|
||||
"PAYMENT_DOJO_NOTIFICATION_",
|
||||
)
|
||||
}
|
||||
if paymentstatus.DojoStatusPrefix != "PAYMENT_DOJO_STATUS_" {
|
||||
t.Fatalf(
|
||||
"DojoStatusPrefix = %q, want %q",
|
||||
paymentstatus.DojoStatusPrefix,
|
||||
"PAYMENT_DOJO_STATUS_",
|
||||
)
|
||||
}
|
||||
if paymentstatus.DojoNotificationPrefix == paymentstatus.DojoStatusPrefix {
|
||||
t.Fatal("Dojo prefixes must differ")
|
||||
}
|
||||
}
|
||||
@ -2,8 +2,17 @@
|
||||
|
||||
builtVersion is a const in main.go
|
||||
|
||||
#### v1.3.5 - 23 July 2026
|
||||
fixed dispenser delivery confirmation
|
||||
|
||||
#### v1.3.4 - 23 July 2026
|
||||
added support for Dojo terminal-unavailable responses
|
||||
|
||||
#### v1.3.3 - 21 July 2026
|
||||
added PDQ status streaming to the payment flow to allow the front end to display the status of the PDQ terminal
|
||||
|
||||
#### 1.3.2 - 20 July 2026
|
||||
make dojo decline payment it case of signature required
|
||||
make dojo decline payment in case of signature required
|
||||
|
||||
#### 1.3.1 - 17 July 2026
|
||||
added receipt printing functionality to the Dojo and Paybridge payment flow
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user