diff --git a/CREDITCALL_STREAMING.md b/CREDITCALL_STREAMING.md index cf73b90..f326775 100644 --- a/CREDITCALL_STREAMING.md +++ b/CREDITCALL_STREAMING.md @@ -2,7 +2,7 @@ Deploy ChipDNAClientCLI, then hardlink, then Operafyne. No database or configuration migration is needed. The existing CreditCall provider selection enables the new -sale route; CreditCall does not implement the generic payment provider interface. +SALE and PREAUTH routes; CreditCall does not implement the generic payment provider interface. ## Contracts @@ -10,6 +10,15 @@ sale route; CreditCall does not implement the generic payment provider interface `{"amount":1234,"confirmNo":"BOOKING-123","currency":"GBP"}`. Amount remains in minor units. CreditCall uses its existing SDK transaction reference generation. +`POST /api/payment/preauth` accepts string fields `amount`, `transactionType` +and `checkoutDate`. Positive preauth sends the existing `Sale` input and checkout +string; zero-value verification sends `{"amount":"","transactionType":"AccountVerification","checkoutDate":""}`. +The returned `ACCOUNT VERIFICATION` type is matched case-insensitively and succeeds +without persistence. Returned approved `SALE` schedules existing SQL persistence +using provider-returned `TOTAL_AMOUNT`, never the request amount. PREAUTH does not +confirm. Checkout remains departure-derived midnight UTC in the existing string +format; SQL's existing date conversion and 48-hour release calculation are unchanged. + For CreditCall, responses contain newline-delimited JSON: ```json @@ -19,7 +28,7 @@ For CreditCall, responses contain newline-delimited JSON: `outcome` is `approved`, `declined`, `cancelled`, `timeout`, or `error`. Approval is produced only by the shared CreditCall transaction/finalization core, including -the existing confirmation behavior. `httpStatus` preserves the equivalent legacy +SALE confirmation behavior; PREAUTH uses its existing unconfirmed-result rules. `httpStatus` preserves the equivalent legacy operation status even after streaming commits HTTP 200. `status` preserves the existing processor `StatusRec`. Failure `message` preserves the plain description used by the legacy flow, including its existing interpretation quirks. @@ -31,20 +40,32 @@ continue using their existing `{"type":"result","response":...}` contract. Hardlink calls `POST /start-transaction-stream/` on ChipDNAClientCLI with `{"amount":"1234","transactionType":"Sale"}`. Its NDJSON consists of allowlisted -`status` frames (`source`, `value`), a single `result` containing the required SDK -fields, or a sanitized `error`. Receipt XML may be a JSON string field needed by +`status` frames (`source`, `value`), a single `result` containing allowlisted SDK +fields, or a sanitized `error`. `TRANSACTION_TYPE` and optional `TOTAL_AMOUNT` +are included only when returned by the provider. They are never synthesized and +are not added to the kiosk-facing result. AccountVerification normally omits +`TOTAL_AMOUNT`. Receipt XML may be a JSON string field needed by hardlink's existing receipt handler; it is never a raw line in the stream. -Confirmation continues through the existing, separate XML endpoint. +SALE confirmation continues through the existing, separate XML endpoint. ## Lifetime and compatibility - `/start-transaction/`, `/takepayment`, and `/takepreauth` retain their existing - external contracts. Preauthorization and SQL persistence/release remain legacy. -- Start and each confirmation call retain their independent 300-second timeout. + external contracts. Both SALE and PREAUTH now stream to Operafyne; existing SQL + persistence/release behavior remains unchanged. +- Operafyne uses a private 120-second CreditCall client for SALE and PREAUTH. + This is only the kiosk wait boundary; Dojo/PayBridge clients are unchanged. + Timeout/cancellation is technical, not an authoritative decline or automatically + retryable result. It never triggers a second start or legacy endpoint fallback. +- Validation uses the inbound request. At financial dispatch handoff, hardlink + detaches cancellation with `context.WithoutCancel`. Inbound cancellation and + write failures control only delivery; result processing, SALE confirmation, + receipts and PREAUTH persistence scheduling continue independently. +- Start and each SALE confirmation call retain their independent 300-second timeout. Confirmation still uses two attempts, retrying transport/read errors with the existing two-second delay. The generic whole-operation timeout is not used. - Kiosk cancellation or failed/blocked kiosk delivery does not cancel upstream reading, - confirmation, or receipt handling. Progress queues may drop hints when full; + confirmation, receipt handling, or PREAUTH persistence scheduling. Progress queues may drop hints when full; final results have a separate slot and are delivered at most once. - Only the response writer writes frames. Transaction execution never waits for progress delivery. No additional delivery timer or whole-operation deadline @@ -68,7 +89,7 @@ Confirmation continues through the existing, separate XML endpoint. timeout outcomes, confirmation, retry, receipts, PMS posting, or business state. - After timeout, an ambiguous start error, an asynchronous SDK error during an active window, or reference reuse/overlap, progress remains suppressed for that - `Client` lifetime. New transactions, elapsed time, callbacks and automatic + `Client` lifetime across SALE and PREAUTH. New transactions, elapsed time, callbacks and automatic reconnect do not reset the guard. Payments remain enabled. - Only the exact synchronous `ClientNotConnectedToServer` error safely clears an unsuppressed window and releases its unused reference: SDK 3.17 `StartCommand` @@ -91,6 +112,13 @@ with MSBuild, then run `Tests/bin/Debug/ChipDNAClient.StreamingTests.exe`. The test executable links the production streaming code and needs no terminal. In Operafyne, run `go test -count=1 ./...`; the service tests cover structured -CreditCall sales, unchanged preauth requests, failure/retry parity, and existing +CreditCall SALE/PREAUTH, unchanged legacy requests, the 120-second UX boundary, +cancellation after dispatch, failure/retry parity, and existing Dojo/PayBridge response handling. Run `go vet ./...`, `go build ./...`, and `git diff --check` in both Go repositories. + +Physical account-verification testing confirmed approved `ACCOUNT VERIFICATION` +with no `TOTAL_AMOUNT`. A monetary `/takepayment` test confirmed returned minor +units; positive PREAUTH through the new transport still needs physical Miura +validation. Automated tests do not replace that check. Receipt fields such as +`NoChargeDeclaration` retain the existing provider-entry rendering behavior. diff --git a/cmd/hardlink/main.go b/cmd/hardlink/main.go index e6ad59c..abe2b48 100644 --- a/cmd/hardlink/main.go +++ b/cmd/hardlink/main.go @@ -33,7 +33,7 @@ import ( ) const ( - buildVersion = "v2.0.0" + buildVersion = "v2.0.1" serviceName = "hardlink" pollingFrequency = 8 * time.Second ) diff --git a/internal/creditcall/creditcall.go b/internal/creditcall/creditcall.go index 9ef92c6..0bf31da 100644 --- a/internal/creditcall/creditcall.go +++ b/internal/creditcall/creditcall.go @@ -125,6 +125,15 @@ func BuildPaymentRedirectURL(result map[string]string) string { } func BuildPreauthRedirectURL(result map[string]string) (string, bool) { + approved, persist := PreauthDecision(result) + if approved { + return BuildSuccessURL(result), persist + } + return BuildFailureURL(result[types.TransactionResult], result[types.Errors]), false +} + +// PreauthDecision preserves the returned-type approval and persistence rules. +func PreauthDecision(result map[string]string) (approved, persist bool) { res := result[types.TransactionResult] tType := result[types.TransactionType] @@ -136,7 +145,7 @@ func BuildPreauthRedirectURL(result map[string]string) (string, bool) { log.WithField(types.LogResult, result[types.TransactionResult]). Info("Account verification approved") - return BuildSuccessURL(result), false + return true, false // Transaction type Sale? case strings.EqualFold(tType, types.SaleTransactionType): @@ -144,12 +153,12 @@ func BuildPreauthRedirectURL(result map[string]string) (string, bool) { log.WithField(types.LogResult, result[types.ConfirmResult]). Info("Amount preauthorized successfully") - return BuildSuccessURL(result), true + return true, true } } // Not approved - return BuildFailureURL(res, result[types.Errors]), false + return false, false } func BuildSuccessURL(result map[string]string) string { diff --git a/internal/creditcall/preauth_test.go b/internal/creditcall/preauth_test.go new file mode 100644 index 0000000..57c07ff --- /dev/null +++ b/internal/creditcall/preauth_test.go @@ -0,0 +1,35 @@ +package creditcall + +import ( + "strings" + "testing" + + "gitea.futuresens.co.uk/futuresens/hardlink/internal/types" +) + +func TestPreauthLegacyDecision(t *testing.T) { + for _, tc := range []struct { + name, result, kind string + approved, save bool + }{ + {"sale", "APPROVED", "SALE", true, true}, + {"verification", "APPROVED", "ACCOUNT VERIFICATION", true, false}, + {"mixed case", "Approved", "Account Verification", true, false}, + {"request spelling is not result spelling", "APPROVED", "AccountVerification", false, false}, + {"missing type", "APPROVED", "", false, false}, + {"declined sale", "DECLINED", "SALE", false, false}, + {"declined verification", "DECLINED", "ACCOUNT VERIFICATION", false, false}, + {"missing result", "", "SALE", false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + fields := map[string]string{types.TransactionResult: tc.result, types.TransactionType: tc.kind, types.Errors: "provider detail"} + redirect, save := BuildPreauthRedirectURL(fields) + if strings.HasPrefix(redirect, "/successful?") != tc.approved || save != tc.save { + t.Errorf("BuildPreauthRedirectURL(%v) = %q,%t, want approved=%t save=%t", fields, redirect, save, tc.approved, tc.save) + } + if _, ok := fields[types.TotalAmount]; ok { + t.Error("BuildPreauthRedirectURL fabricated TOTAL_AMOUNT") + } + }) + } +} diff --git a/internal/handlers/creditcall_preauth.go b/internal/handlers/creditcall_preauth.go new file mode 100644 index 0000000..cf8c148 --- /dev/null +++ b/internal/handlers/creditcall_preauth.go @@ -0,0 +1,53 @@ +package handlers + +import ( + "context" + "net/http" + + "gitea.futuresens.co.uk/futuresens/cmstypes" + "gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall" + "gitea.futuresens.co.uk/futuresens/hardlink/internal/types" + "gitea.futuresens.co.uk/futuresens/logging" +) + +type creditCallPreauthRequest struct { + Amount string `json:"amount"` + TransactionType string `json:"transactionType"` + CheckoutDate string `json:"checkoutDate"` +} + +func (app *App) streamCreditCallPreauth(w http.ResponseWriter, r *http.Request) { + app.streamCreditCall(w, r, true) +} + +// executeCreditCallPreauth finalizes the existing unconfirmed transaction. +// The outcome container and its wire projections are shared with SALE; its execution is not. +func (app *App) executeCreditCallPreauth(ctx context.Context, request cmstypes.TransactionRec, + start func(*http.Client) (creditcall.TransactionResultXML, error)) creditCallSaleOutcome { + const op = logging.Op("takePreauthorization") + outcome := creditCallSaleOutcome{ + HTTPStatus: http.StatusBadGateway, + Status: cmstypes.StatusRec{Code: http.StatusInternalServerError, Message: "500 Internal server error"}, + } + transaction, err := start(app.creditCallClient()) + if err != nil { + logging.Error(types.ServiceName, err.Error(), "Preauth processing error", string(op), "", "", 0) + outcome.FailureType = types.ResultError + outcome.FailureDescription = "No response from payment processor" + return outcome + } + var result creditcall.PaymentResult + result.FillFromTransactionResult(transaction) + app.printCreditCallReceipt(result.CardholderReceipt) + approved, persist := creditcall.PreauthDecision(result.Fields) + outcome.HTTPStatus = http.StatusOK + outcome.Status = result.Status + outcome.Payment = result + outcome.Approved = approved + outcome.FailureType = result.Fields[types.TransactionResult] + outcome.FailureDescription = result.Fields[types.Errors] + if persist { + go app.persistPreauth(ctx, result.Fields, request.CheckoutDate) + } + return outcome +} diff --git a/internal/handlers/creditcall_preauth_test.go b/internal/handlers/creditcall_preauth_test.go new file mode 100644 index 0000000..09f5af6 --- /dev/null +++ b/internal/handlers/creditcall_preauth_test.go @@ -0,0 +1,393 @@ +package handlers + +import ( + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "encoding/xml" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "gitea.futuresens.co.uk/futuresens/cmstypes" + "gitea.futuresens.co.uk/futuresens/hardlink/internal/types" + "gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus" +) + +type preauthTestConnector struct { + inserts chan []driver.NamedValue + count atomic.Int32 +} + +func (c *preauthTestConnector) Connect(context.Context) (driver.Conn, error) { + return &preauthTestConn{c}, nil +} +func (c *preauthTestConnector) Driver() driver.Driver { return preauthTestDriver{c} } + +type preauthTestDriver struct{ c *preauthTestConnector } + +func (d preauthTestDriver) Open(string) (driver.Conn, error) { return &preauthTestConn{d.c}, nil } + +type preauthTestConn struct{ c *preauthTestConnector } + +func (*preauthTestConn) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("unexpected prepare") +} +func (*preauthTestConn) Close() error { return nil } +func (*preauthTestConn) Begin() (driver.Tx, error) { return nil, errors.New("unexpected begin") } +func (*preauthTestConn) Ping(context.Context) error { return nil } +func (c *preauthTestConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if !strings.Contains(query, "INSERT INTO dbo.Preauthorizations") { + return nil, errors.New("unexpected SQL") + } + c.c.count.Add(1) + c.c.inserts <- append([]driver.NamedValue(nil), args...) + return driver.RowsAffected(1), nil +} +func preauthTestDatabase(t *testing.T, app *App) *preauthTestConnector { + t.Helper() + c := &preauthTestConnector{inserts: make(chan []driver.NamedValue, 4)} + app.db = sql.OpenDB(c) + app.cfg.LogDir = t.TempDir() + t.Cleanup(func() { app.db.Close() }) + return c +} +func waitPreauthInsert(t *testing.T, c *preauthTestConnector) map[string]any { + t.Helper() + select { + case args := <-c.inserts: + result := map[string]any{} + for _, arg := range args { + result[arg.Name] = arg.Value + } + return result + case <-time.After(3 * time.Second): + t.Fatal("preauth persistence did not reach SQL") + } + return nil +} +func preauthLegacyRequest(amount, kind, checkout string) *http.Request { + body, _ := xml.Marshal(cmstypes.TransactionRec{AmountMinorUnits: amount, TransactionType: kind, CheckoutDate: checkout}) + r := httptest.NewRequest(http.MethodPost, "/takepreauth", strings.NewReader(string(body))) + r.Header.Set("Content-Type", "text/xml") + return r +} + +func TestCreditCallPreauthLegacyCharacterization(t *testing.T) { + for _, tc := range []struct { + name, amount, requestType, result, resultType, total string + save, approved bool + }{ + {"positive", "12000", "Sale", "APPROVED", "SALE", "3100", true, true}, + {"declined", "12000", "Sale", "DECLINED", "SALE", "", false, false}, + {"verification", "", "AccountVerification", "APPROVED", "ACCOUNT VERIFICATION", "", false, true}, + {"verification failure", "", "AccountVerification", "DECLINED", "ACCOUNT VERIFICATION", "", false, false}, + {"unexpected type", "12000", "Sale", "APPROVED", "Refund", "", false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + var starts, prints int + checkout := "" + if tc.amount != "" { + checkout = "2026-09-11 00:00:00 +0000" + } + fields := map[string]string{types.TransactionResult: tc.result, types.TransactionType: tc.resultType, types.Reference: "preauth-ref", types.PanMasked: "************1133", types.CardType: "Visa", types.ExpiryDate: "1228", types.CardHash: "card-hash", types.CardReference: "card-reference", types.ReceiptDataCardholder: "receipt"} + if tc.total != "" { + fields[types.TotalAmount] = tc.total + } + app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) { + starts++ + if r.URL.Path != "/start-transaction/" { + t.Errorf("PREAUTH upstream path=%s, want start only", r.URL.Path) + } + var input cmstypes.TransactionRec + if err := xml.NewDecoder(r.Body).Decode(&input); err != nil { + t.Error(err) + } + if input.AmountMinorUnits != tc.amount || input.TransactionType != tc.requestType || input.CheckoutDate != checkout { + t.Errorf("PREAUTH input=%+v, want amount=%q type=%q checkout=%q", input, tc.amount, tc.requestType, checkout) + } + // Legacy ignores upstream HTTP status when XML contains a result. + w.WriteHeader(http.StatusBadGateway) + io.WriteString(w, chipDNAFixture(t, fields)) + }) + c := preauthTestDatabase(t, app) + app.creditCallReceipt = func(receipt string) { + prints++ + if receipt != "receipt" { + t.Errorf("receipt=%q, want receipt", receipt) + } + } + recorder := httptest.NewRecorder() + app.takePreauthorization(recorder, preauthLegacyRequest(tc.amount, tc.requestType, checkout)) + var response cmstypes.ResponseRec + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if recorder.Code != 200 || response.Status.Code != 200 || starts != 1 || prints != 1 || strings.HasPrefix(response.Data, "/successful?") != tc.approved { + t.Errorf("PREAUTH response=%+v HTTP=%d starts=%d prints=%d, want approved=%t one start/receipt", response, recorder.Code, starts, prints, tc.approved) + } + if tc.save { + got := waitPreauthInsert(t, c) + if got["TotalMinorUnits"] != "3100" || got["TxnReference"] != "preauth-ref" { + t.Errorf("persisted=%v, want provider amount/reference", got) + } + departure := time.Date(2026, 9, 11, 0, 0, 0, 0, time.Local).UTC() + if got["DepartureDate"] != departure || got["ReleaseDate"] != departure.Add(48*time.Hour) { + t.Errorf("persisted dates=%v, want existing local midnight plus 48 hours", got) + } + } else if c.count.Load() != 0 { + t.Error("non-persisting result inserted SQL") + } + }) + } +} + +func preauthStreamRequest(amount, kind, checkout string) *http.Request { + body, _ := json.Marshal(creditCallPreauthRequest{Amount: amount, TransactionType: kind, CheckoutDate: checkout}) + r := httptest.NewRequest(http.MethodPost, "/api/payment/preauth", strings.NewReader(string(body))) + r.Header.Set("Content-Type", "application/json") + return r +} + +func TestCreditCallPreauthStreamingParity(t *testing.T) { + for _, tc := range []struct { + name, result, kind, total string + approved, persist bool + }{ + {"monetary", "APPROVED", "SALE", "3100", true, true}, + {"verification", "APPROVED", "ACCOUNT VERIFICATION", "", true, false}, + {"declined", "DECLINED", "SALE", "", false, false}, + {"verification failed", "DECLINED", "ACCOUNT VERIFICATION", "", false, false}, + {"unknown approved type", "APPROVED", "AccountVerification", "", false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + fields := map[string]string{types.TransactionResult: tc.result, types.TransactionType: tc.kind, types.Reference: "preauth-ref", types.PanMasked: "************1133", types.CardType: "Visa", types.ExpiryDate: "1228", types.CardHash: "hash", types.CardReference: "cardref", types.ReceiptDataCardholder: "receipt"} + if tc.total != "" { + fields[types.TotalAmount] = tc.total + } + amount, kind, date := "12000", "Sale", "2026-09-11 00:00:00 +0000" + if strings.Contains(tc.name, "verification") { + amount, kind, date = "", "AccountVerification", "" + } + var legacy cmstypes.ResponseRec + for _, stream := range []bool{false, true} { + calls, prints := 0, 0 + app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + if stream { + if r.URL.Path != "/start-transaction-stream/" { + t.Errorf("stream called %s, want one generic start", r.URL.Path) + } + var input creditCallPreauthRequest + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + t.Error(err) + } + if input.Amount != amount || input.TransactionType != kind { + t.Errorf("upstream input=%+v, want %q/%q", input, amount, kind) + } + for _, event := range []struct{ source, value string }{{"UPDATE", "CardRequested"}, {"CARD_STATUS", "Inserted"}, {"UPDATE", "CardRemovalRequested"}, {"CARD_STATUS", "Removed"}, {"UPDATE", "CardRequested"}, {"UPDATE", "CardRemovalEnforced"}, {"UPDATE", "PinEntryStarted"}, {"UPDATE", "OnlineAuthCompleted"}} { + json.NewEncoder(w).Encode(map[string]any{"type": "status", "source": event.source, "value": event.value}) + } + json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields}) + json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields}) + } else { + io.WriteString(w, chipDNAFixture(t, fields)) + } + }) + c := preauthTestDatabase(t, app) + app.creditCallReceipt = func(receipt string) { + prints++ + if receipt != "receipt" { + t.Errorf("receipt=%q, want retained receipt", receipt) + } + } + recorder := httptest.NewRecorder() + if stream { + app.streamCreditCallPreauth(recorder, preauthStreamRequest(amount, kind, date)) + frames := decodePaymentStream(t, recorder.Body) + var statuses []string + for _, frame := range frames { + if frame.Type == "status" { + statuses = append(statuses, frame.Code) + } + } + want := []string{paymentstatus.PresentCard, paymentstatus.DoNotRemoveCard, paymentstatus.RemoveCard, paymentstatus.PresentCard, paymentstatus.RemoveCard, paymentstatus.EnterPIN, paymentstatus.PleaseWait} + if !reflect.DeepEqual(statuses, want) { + t.Errorf("preauth statuses=%v, want %v", statuses, want) + } + final := frames[len(frames)-1].Result + if final == nil { + t.Fatal("missing structured final") + } + if (final.Outcome == "approved") != tc.approved || final.Status != legacy.Status || final.HTTPStatus != 200 { + t.Errorf("stream final=%+v, legacy=%+v", final, legacy) + } + if tc.approved && (final.TransactionReference != "preauth-ref" || final.CardType != "Visa" || final.MaskedCardNumber != "************1133" || final.ExpiryDate != "1228" || final.CardHash != "hash" || final.CardReference != "cardref") { + t.Errorf("preauth fields lost: %+v", final) + } + if len(frames) != len(want)+1 { + t.Errorf("frames=%d, want one final", len(frames)) + } + } else { + app.takePreauthorization(recorder, preauthLegacyRequest(amount, kind, date)) + if err := json.Unmarshal(recorder.Body.Bytes(), &legacy); err != nil { + t.Fatal(err) + } + } + if calls != 1 || prints != 1 { + t.Errorf("calls/prints=%d/%d, want 1/1", calls, prints) + } + if tc.persist { + got := waitPreauthInsert(t, c) + if got["TotalMinorUnits"] != tc.total { + t.Errorf("SQL amount=%v, want provider %q", got["TotalMinorUnits"], tc.total) + } + } else if c.count.Load() != 0 { + t.Error("verification/failure persisted") + } + } + }) + } +} + +func TestCreditCallInboundCancellationAfterDispatch(t *testing.T) { + for _, preauth := range []bool{false, true} { + t.Run(map[bool]string{false: "sale", true: "preauth"}[preauth], func(t *testing.T) { + dispatched := make(chan struct{}) + release := make(chan struct{}) + var starts, confirms, receipts atomic.Int32 + app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/confirm-transaction/" { + confirms.Add(1) + io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: "APPROVED", types.ReceiptDataCardholder: "receipt"})) + return + } + starts.Add(1) + close(dispatched) + <-release + if r.Context().Err() != nil { + t.Errorf("financial upstream was cancelled: %v", r.Context().Err()) + return + } + json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": map[string]string{types.TransactionResult: "APPROVED", types.TransactionType: "SALE", types.TotalAmount: "3100", types.Reference: "delayed-ref", types.ReceiptDataCardholder: "receipt"}}) + }) + c := preauthTestDatabase(t, app) + app.creditCallReceipt = func(receipt string) { + if receipt != "receipt" { + t.Errorf("receipt=%q", receipt) + } + receipts.Add(1) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + recorder := httptest.NewRecorder() + done := make(chan struct{}) + go func() { + defer close(done) + if preauth { + app.streamCreditCallPreauth(recorder, preauthStreamRequest("12000", "Sale", "2026-09-11 00:00:00 +0000").WithContext(ctx)) + } else { + app.streamCreditCallSale(recorder, saleRequest(true).WithContext(ctx)) + } + }() + select { + case <-dispatched: + case <-time.After(3 * time.Second): + close(release) + t.Fatal("no upstream dispatch") + } + cancel() // Exact acceptance boundary: upstream has observed StartTransaction. + close(release) + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("cancelled inbound request stopped finalization") + } + wantConfirms := int32(1) + if preauth { + wantConfirms = 0 + got := waitPreauthInsert(t, c) + if got["TotalMinorUnits"] != "3100" { + t.Errorf("delayed persistence=%v", got) + } + if c.count.Load() != 1 { + t.Errorf("delayed persistence count=%d, want exactly one", c.count.Load()) + } + } + if starts.Load() != 1 || receipts.Load() != 1 || confirms.Load() != wantConfirms { + t.Errorf("starts/receipts/confirms=%d/%d/%d, want 1/1/%d", starts.Load(), receipts.Load(), confirms.Load(), wantConfirms) + } + if recorder.Body.Len() != 0 { + t.Errorf("cancelled delivery wrote %s", recorder.Body.String()) + } + }) + } +} + +func TestCreditCallPreauthMissingAmountNeverSynthesized(t *testing.T) { + app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": map[string]string{types.TransactionResult: "APPROVED", types.TransactionType: "SALE", types.Reference: "missing-amount"}}) + }) + c := preauthTestDatabase(t, app) + app.creditCallReceipt = func(string) {} + recorder := httptest.NewRecorder() + app.streamCreditCallPreauth(recorder, preauthStreamRequest("9999", "Sale", "2026-09-11 00:00:00 +0000")) + frames := decodePaymentStream(t, recorder.Body) + if frames[0].Result.Outcome != "approved" { + t.Fatalf("missing amount changed approval: %+v", frames[0].Result) + } + deadline := time.Now().Add(3 * time.Second) + for { + data, err := os.ReadFile(app.spoolPath()) + var record preauthSpoolRecord + if err == nil && json.Unmarshal(data, &record) == nil { + if _, ok := record.Fields[types.TotalAmount]; ok { + t.Errorf("spool fabricated amount: %v", record.Fields) + } + if record.CheckoutDate != "2026-09-11 00:00:00 +0000" { + t.Errorf("spool checkout=%q", record.CheckoutDate) + } + break + } + if time.Now().After(deadline) { + t.Fatal("missing provider amount did not retain existing spool fallback") + } + time.Sleep(time.Millisecond) + } + if c.count.Load() != 0 { + t.Error("SQL inserted fabricated amount") + } +} + +func TestCreditCallPreauthStreamFailureAndCancelledBeforeDispatch(t *testing.T) { + for _, body := range []string{"", "{", "{}\n", "\n", "{\"type\":\"result\",\"result\":{}}\n", "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"APPROVED\"}}"} { + app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, body) }) + calls := 0 + transport := app.creditCallTransport + app.creditCallTransport = creditCallRoundTrip(func(r *http.Request) (*http.Response, error) { calls++; return transport.RoundTrip(r) }) + app.creditCallReceipt = func(string) { t.Error("malformed stream printed receipt") } + recorder := httptest.NewRecorder() + app.streamCreditCallPreauth(recorder, preauthStreamRequest("", "AccountVerification", "")) + frames := decodePaymentStream(t, recorder.Body) + if calls != 1 || len(frames) != 1 || frames[0].Result.Outcome != "error" || frames[0].Result.HTTPStatus != 502 { + t.Errorf("invalid stream %q: calls=%d frames=%+v", body, calls, frames) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + app.streamCreditCallPreauth(httptest.NewRecorder(), preauthStreamRequest("", "AccountVerification", "").WithContext(ctx)) + if calls != 1 { + t.Error("already-cancelled request dispatched a transaction") + } + } +} diff --git a/internal/handlers/creditcall_stream.go b/internal/handlers/creditcall_stream.go index 27000b9..ff3f13f 100644 --- a/internal/handlers/creditcall_stream.go +++ b/internal/handlers/creditcall_stream.go @@ -63,6 +63,10 @@ func (outcome creditCallSaleOutcome) streamResult() creditCallStreamResult { func (app *App) EnableCreditCallStreaming() { app.creditCallStreamEnabled = true } func (app *App) streamCreditCallSale(w http.ResponseWriter, r *http.Request) { + app.streamCreditCall(w, r, false) +} + +func (app *App) streamCreditCall(w http.ResponseWriter, r *http.Request, preauth bool) { setPaymentCORS(w) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) @@ -97,6 +101,10 @@ func (app *App) streamCreditCallSale(w http.ResponseWriter, r *http.Request) { w.WriteHeader(status) send(paymentStreamMessage{Type: "result", Result: &result}) } + if preauth && !app.creditCallStreamEnabled { + reject(http.StatusServiceUnavailable, "CreditCall preauthorization streaming is not enabled") + return + } if !app.isPayment && !app.cfg.TestMode { mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Error", "Attempted payment while payment processing is disabled") reject(http.StatusServiceUnavailable, "Payment processing is disabled") @@ -111,33 +119,53 @@ func (app *App) streamCreditCallSale(w http.ResponseWriter, r *http.Request) { return } defer r.Body.Close() - var request SalePaymentRequest - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - reject(http.StatusBadRequest, "Invalid JSON payload") - return - } - if request.Amount <= 0 { - reject(http.StatusBadRequest, "Amount must be greater than zero") - return + var transaction cmstypes.TransactionRec + if preauth { + var request creditCallPreauthRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + reject(http.StatusBadRequest, "Invalid JSON payload") + return + } + transaction = cmstypes.TransactionRec{AmountMinorUnits: request.Amount, TransactionType: request.TransactionType, CheckoutDate: request.CheckoutDate} + } else { + var request SalePaymentRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + reject(http.StatusBadRequest, "Invalid JSON payload") + return + } + if request.Amount <= 0 { + reject(http.StatusBadRequest, "Amount must be greater than zero") + return + } + transaction = cmstypes.TransactionRec{AmountMinorUnits: strconv.FormatInt(request.Amount, 10), TransactionType: "Sale"} } if _, ok := w.(http.Flusher); !ok { reject(http.StatusInternalServerError, "Streaming payment updates are not supported") return } + // Validation belongs to the incoming request. This handoff owns financial dispatch. + if r.Context().Err() != nil { + return + } + financialContext := context.WithoutCancel(r.Context()) progress := make(chan string, 128) finished := make(chan creditCallSaleOutcome, 1) go func() { - outcome := app.executeCreditCallSale(cmstypes.TransactionRec{ - AmountMinorUnits: strconv.FormatInt(request.Amount, 10), TransactionType: "Sale", - }, func(client *http.Client) (creditcall.TransactionResultXML, error) { - return callChipDNAStream(client, request.Amount, func(code string) { + start := func(client *http.Client) (creditcall.TransactionResultXML, error) { + return callChipDNATransactionStream(financialContext, client, transaction, func(code string) { select { case progress <- code: default: // Observational progress may be dropped under backpressure. } }) - }) + } + var outcome creditCallSaleOutcome + if preauth { + outcome = app.executeCreditCallPreauth(financialContext, transaction, start) + } else { + outcome = app.executeCreditCallSale(transaction, start) + } close(progress) finished <- outcome // Independent of the bounded progress queue. }() @@ -163,16 +191,22 @@ func (app *App) streamCreditCallSale(w http.ResponseWriter, r *http.Request) { } func callChipDNAStream(client *http.Client, amount int64, onStatus func(string)) (creditcall.TransactionResultXML, error) { + return callChipDNATransactionStream(context.Background(), client, cmstypes.TransactionRec{ + AmountMinorUnits: strconv.FormatInt(amount, 10), TransactionType: "Sale", + }, onStatus) +} + +func callChipDNATransactionStream(ctx context.Context, client *http.Client, transaction cmstypes.TransactionRec, onStatus func(string)) (creditcall.TransactionResultXML, error) { var result creditcall.TransactionResultXML payload, err := json.Marshal(struct { Amount string `json:"amount"` TransactionType string `json:"transactionType"` - }{strconv.FormatInt(amount, 10), "Sale"}) + }{transaction.AmountMinorUnits, transaction.TransactionType}) if err != nil { return result, err } - // Background plus Client.Timeout reproduces the independent per-call bound. - req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, types.LinkStartTransactionStream, bytes.NewReader(payload)) + // Dispatch owns ctx; Client.Timeout retains the independent 300-second call bound. + req, err := http.NewRequestWithContext(ctx, http.MethodPost, types.LinkStartTransactionStream, bytes.NewReader(payload)) if err != nil { return result, err } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 528c6dd..b835309 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -78,6 +78,7 @@ func (app *App) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/ping-pdq", app.fetchChipDNAStatus) mux.HandleFunc("/logerror", app.onChipDNAError) mux.HandleFunc("/api/payment/sale", app.salePayment) + mux.HandleFunc("/api/payment/preauth", app.streamCreditCallPreauth) } func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) { @@ -86,9 +87,6 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) { var ( theResponse cmstypes.ResponseRec theRequest cmstypes.TransactionRec - trResult creditcall.TransactionResultXML - result creditcall.PaymentResult - save bool ) theResponse.Status.Code = http.StatusInternalServerError @@ -149,39 +147,18 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) { theRequest.TransactionType, ) - client := app.creditCallClient() - - // ---- START TRANSACTION ---- - - body, err = callChipDNA(client, types.LinkStartTransaction, body) - if err != nil { - logging.Error(types.ServiceName, err.Error(), "Preauth processing error", string(op), "", "", 0) - - theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "No response from payment processor") - writeTransactionResult(w, http.StatusBadGateway, theResponse) - return - } - - if err := trResult.ParseTransactionResult(body); err != nil { - logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0) - } - - result.FillFromTransactionResult(trResult) - - // ---- PRINT RECEIPT ---- - - app.printCreditCallReceipt(result.CardholderReceipt) - - // ---- REDIRECT ---- - - theResponse.Status = result.Status - theResponse.Data, save = creditcall.BuildPreauthRedirectURL(result.Fields) - - if save { - go app.persistPreauth(context.Background(), result.Fields, theRequest.CheckoutDate) - } - - writeTransactionResult(w, http.StatusOK, theResponse) + outcome := app.executeCreditCallPreauth(context.Background(), theRequest, func(client *http.Client) (creditcall.TransactionResultXML, error) { + var transaction creditcall.TransactionResultXML + response, err := callChipDNA(client, types.LinkStartTransaction, body) + if err != nil { + return transaction, err + } + if err := transaction.ParseTransactionResult(response); err != nil { + logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0) + } + return transaction, nil + }) + writeTransactionResult(w, outcome.HTTPStatus, outcome.legacyResponse()) } func (app *App) takePayment(w http.ResponseWriter, r *http.Request) { diff --git a/release notes.md b/release notes.md index 5707445..2d67969 100644 --- a/release notes.md +++ b/release notes.md @@ -2,6 +2,9 @@ builtVersion is a const in main.go +#### v2.0.1 - 10 September 2026 +feat: add CreditCall preauth streaming + #### v2.0.0 - 08 September 2026 feat: stream CreditCall payment progress and results