diff --git a/api/model/transaction.go b/api/model/transaction.go index e94b62e4..6332fe86 100644 --- a/api/model/transaction.go +++ b/api/model/transaction.go @@ -44,6 +44,16 @@ type RecordTransaction struct { Destinations []model.Distribution `json:"destinations"` MetaData map[string]interface{} `json:"meta_data"` EffectiveDate *time.Time `json:"effective_date,omitempty"` + + // DryRun projects the transaction's effect on the source and destination + // balances and returns the result without applying it. No transaction row + // is written, no balance changes, nothing is queued, no webhook fires, and + // the reference is not consumed. + // + // A dry run always responds synchronously with 200 rather than 201, and + // takes precedence over skip_queue: a projection is inherently an immediate + // answer about what a real post would do. + DryRun bool `json:"dry_run"` } // BulkTransactionRequest is the public API request shape for creating a batch @@ -54,6 +64,15 @@ type BulkTransactionRequest struct { Atomic bool `json:"atomic"` RunAsync bool `json:"run_async"` SkipQueue bool `json:"skip_queue"` + + // DryRun projects the batch and returns the result without applying any of + // it. The projection answers synchronously with 200, so run_async is + // ignored. + // + // Items are projected against each other's effects only when skip_queue is + // set, mirroring how the batch would really run; the response reports which + // mode was used. + DryRun bool `json:"dry_run"` } func (r *BulkTransactionRequest) ToBulkTransactionRequest() *model.BulkTransactionRequest { @@ -80,6 +99,14 @@ type InflightUpdate struct { // SkipQueue processes the commit/void synchronously instead of routing it // through the inflight-commit queue (the default). SkipQueue bool `json:"skip_queue"` + + // DryRun projects the commit or void and returns the resulting balances + // without settling anything: no settlement transaction is recorded and the + // hold stays in place. + // + // A void always releases the full remaining hold, so any amount sent with + // one is ignored. + DryRun bool `json:"dry_run"` } // MaxBulkInflightItems caps the number of transactions accepted in a single diff --git a/api/transaction_dryrun_api_test.go b/api/transaction_dryrun_api_test.go new file mode 100644 index 00000000..0f8598bd --- /dev/null +++ b/api/transaction_dryrun_api_test.go @@ -0,0 +1,280 @@ +package api + +import ( + "net/http" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + blnk "github.com/blnkfinance/blnk" + model2 "github.com/blnkfinance/blnk/api/model" + "github.com/blnkfinance/blnk/internal/request" + "github.com/blnkfinance/blnk/model" +) + +// newDryRunFixture creates a ledger with a funded source balance and an empty +// destination, and returns their ids. +func newDryRunFixture(t *testing.T, b *blnk.Blnk) (string, string) { + t.Helper() + + ledger, err := b.CreateLedger(model.Ledger{Name: gofakeit.Name()}) + require.NoError(t, err) + + source, err := b.CreateBalance(t.Context(), model.Balance{LedgerID: ledger.LedgerID, Currency: "USD"}) + require.NoError(t, err) + + destination, err := b.CreateBalance(t.Context(), model.Balance{LedgerID: ledger.LedgerID, Currency: "USD"}) + require.NoError(t, err) + + // Fund the source from the world account so it has something to spend. + funding := &model.Transaction{ + Reference: "fund_" + model.GenerateUUIDWithSuffix("ref"), + Source: "@World", + Destination: source.BalanceID, + Amount: 500, + Precision: 100, + Currency: "USD", + AllowOverdraft: true, + SkipQueue: true, + } + _, err = b.QueueTransaction(t.Context(), funding) + require.NoError(t, err) + + return source.BalanceID, destination.BalanceID +} + +// TestDryRunTransactionReturns200AndWritesNothing covers the contract at the +// HTTP boundary: a projection answers with 200 rather than 201, and the +// reference it used is still free afterwards. +func TestDryRunTransactionReturns200AndWritesNothing(t *testing.T) { + router, b, err := setupRouter() + require.NoError(t, err) + + source, destination := newDryRunFixture(t, b) + reference := "dryrun_" + model.GenerateUUIDWithSuffix("ref") + + payload := model2.RecordTransaction{ + Amount: 100, + Precision: 100, + Currency: "USD", + Source: source, + Destination: destination, + Reference: reference, + Description: "dry run projection", + DryRun: true, + } + + body, err := request.ToJsonReq(&payload) + require.NoError(t, err) + + var preview model.TransactionPreview + resp, err := SetUpTestRequest(TestRequest{ + Payload: body, + Response: &preview, + Method: http.MethodPost, + Route: "/transactions", + Router: router, + }) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.Code, "a dry run creates nothing, so it must not answer 201") + assert.True(t, preview.DryRun) + assert.True(t, preview.WouldApply) + require.Len(t, preview.Balances, 2) + assert.Equal(t, "50000", preview.Balances[0].CurrentBalance) + assert.Equal(t, "40000", preview.Balances[0].ResultingBalance) + assert.Equal(t, "10000", preview.Balances[1].ResultingBalance) + + // Nothing was recorded, so the reference is still available. + _, err = b.GetTransactionByRef(t.Context(), reference) + assert.Error(t, err, "a dry run must not persist a transaction") + + // And the balances did not move. + after, err := b.GetBalanceByID(t.Context(), source, nil, false) + require.NoError(t, err) + assert.Equal(t, "50000", after.Balance.String(), "a dry run must not change balances") +} + +// TestDryRunTransactionDoesNotConsumeReference checks the idempotency promise: +// previewing with a reference leaves it usable for the real post. +func TestDryRunTransactionDoesNotConsumeReference(t *testing.T) { + router, b, err := setupRouter() + require.NoError(t, err) + + source, destination := newDryRunFixture(t, b) + reference := "shared_" + model.GenerateUUIDWithSuffix("ref") + + preview := model2.RecordTransaction{ + Amount: 100, Precision: 100, Currency: "USD", + Source: source, Destination: destination, Reference: reference, + Description: "dry run projection", DryRun: true, + } + body, err := request.ToJsonReq(&preview) + require.NoError(t, err) + + var previewResp model.TransactionPreview + resp, err := SetUpTestRequest(TestRequest{ + Payload: body, Response: &previewResp, Method: http.MethodPost, Route: "/transactions", Router: router, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code) + + // The same reference must still be accepted for a real post. + real := preview + real.DryRun = false + real.SkipQueue = true + realBody, err := request.ToJsonReq(&real) + require.NoError(t, err) + + var created model.Transaction + realResp, err := SetUpTestRequest(TestRequest{ + Payload: realBody, Response: &created, Method: http.MethodPost, Route: "/transactions", Router: router, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusCreated, realResp.Code, "the dry run must not have consumed the reference") +} + +// TestDryRunTransactionProjectsRejection checks a projected rejection is a 200 +// carrying the same error code a real post would have returned. +func TestDryRunTransactionProjectsRejection(t *testing.T) { + router, b, err := setupRouter() + require.NoError(t, err) + + source, destination := newDryRunFixture(t, b) + + payload := model2.RecordTransaction{ + Amount: 9999, Precision: 100, Currency: "USD", + Source: source, Destination: destination, + Reference: "reject_" + model.GenerateUUIDWithSuffix("ref"), + Description: "dry run projection", + DryRun: true, + } + body, err := request.ToJsonReq(&payload) + require.NoError(t, err) + + var preview model.TransactionPreview + resp, err := SetUpTestRequest(TestRequest{ + Payload: body, Response: &preview, Method: http.MethodPost, Route: "/transactions", Router: router, + }) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.Code, "a projected rejection is a successful answer of \"no\"") + assert.False(t, preview.WouldApply) + require.NotNil(t, preview.Rejection) + assert.Equal(t, "TXN_INSUFFICIENT_FUNDS", preview.Rejection.Code) + assert.Equal(t, "insufficient_funds", preview.Rejection.Reason) +} + +// TestDryRunTransactionOverridesSkipQueue checks the precedence rule: a +// projection is always immediate, whatever queueing the caller asked for. +func TestDryRunTransactionOverridesSkipQueue(t *testing.T) { + router, b, err := setupRouter() + require.NoError(t, err) + + source, destination := newDryRunFixture(t, b) + + payload := model2.RecordTransaction{ + Amount: 100, Precision: 100, Currency: "USD", + Source: source, Destination: destination, + Reference: "override_" + model.GenerateUUIDWithSuffix("ref"), + Description: "dry run projection", + DryRun: true, + SkipQueue: false, + } + body, err := request.ToJsonReq(&payload) + require.NoError(t, err) + + var preview model.TransactionPreview + resp, err := SetUpTestRequest(TestRequest{ + Payload: body, Response: &preview, Method: http.MethodPost, Route: "/transactions", Router: router, + }) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.True(t, preview.DryRun) + require.Len(t, preview.Balances, 2) +} + +// TestDryRunRefundProjectsReversal covers the refund projection: the reversal +// is described, but the parent is not marked refunded. +func TestDryRunRefundProjectsReversal(t *testing.T) { + router, b, err := setupRouter() + require.NoError(t, err) + + source, destination := newDryRunFixture(t, b) + + applied, err := b.QueueTransaction(t.Context(), &model.Transaction{ + Reference: "torefund_" + model.GenerateUUIDWithSuffix("ref"), + Source: source, + Destination: destination, + Amount: 100, + Precision: 100, + Currency: "USD", + SkipQueue: true, + }) + require.NoError(t, err) + + var preview model.TransactionPreview + body, err := request.ToJsonReq(&map[string]interface{}{"dry_run": true}) + require.NoError(t, err) + + resp, err := SetUpTestRequest(TestRequest{ + Payload: body, + Response: &preview, + Method: http.MethodPost, + Route: "/refund-transaction/" + applied.TransactionID, + Router: router, + }) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.True(t, preview.DryRun) + require.Len(t, preview.Balances, 2) + + // The reversal runs the other way: the original destination is debited. + assert.Equal(t, destination, preview.Balances[0].BalanceID) + assert.Equal(t, model.PreviewRoleSource, preview.Balances[0].Role) + + // The parent is untouched, so a real refund afterwards still succeeds. + var refund model.Transaction + realResp, err := SetUpTestRequest(TestRequest{ + Response: &refund, + Method: http.MethodPost, + Route: "/refund-transaction/" + applied.TransactionID, + Router: router, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusCreated, realResp.Code, "a dry run must not mark the parent refunded") +} + +// TestRefundWithoutBodyStillWorks guards the long-standing bodiless refund +// call against the new optional field. +func TestRefundWithoutBodyStillWorks(t *testing.T) { + router, b, err := setupRouter() + require.NoError(t, err) + + source, destination := newDryRunFixture(t, b) + + applied, err := b.QueueTransaction(t.Context(), &model.Transaction{ + Reference: "bodiless_" + model.GenerateUUIDWithSuffix("ref"), + Source: source, + Destination: destination, + Amount: 100, + Precision: 100, + Currency: "USD", + SkipQueue: true, + }) + require.NoError(t, err) + + var created model.Transaction + resp, err := SetUpTestRequest(TestRequest{ + Response: &created, + Method: http.MethodPost, + Route: "/refund-transaction/" + applied.TransactionID, + Router: router, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusCreated, resp.Code, "an empty body must keep meaning \"refund normally\"") +} diff --git a/api/transaction_dryrun_bulk_api_test.go b/api/transaction_dryrun_bulk_api_test.go new file mode 100644 index 00000000..c8e74dfe --- /dev/null +++ b/api/transaction_dryrun_bulk_api_test.go @@ -0,0 +1,171 @@ +package api + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + model2 "github.com/blnkfinance/blnk/api/model" + "github.com/blnkfinance/blnk/internal/request" + "github.com/blnkfinance/blnk/model" +) + +func bulkItem(source, destination string, amount float64, reference string) *model2.RecordTransaction { + return &model2.RecordTransaction{ + Amount: amount, + Precision: 100, + Currency: "USD", + Source: source, + Destination: destination, + Reference: reference, + Description: "bulk dry run", + } +} + +// TestDryRunBulkCumulativeCatchesIntraBatchShortfall covers the case the mode +// exists for: with skip_queue the items really do run one after another, so the +// second item must be judged against what the first one did. +func TestDryRunBulkCumulativeCatchesIntraBatchShortfall(t *testing.T) { + router, b, err := setupRouter() + require.NoError(t, err) + + source, destination := newDryRunFixture(t, b) + + req := model2.BulkTransactionRequest{ + DryRun: true, + SkipQueue: true, + Transactions: []*model2.RecordTransaction{ + // Drains almost all of the 500.00 the fixture funded. + bulkItem(source, destination, 450, "bulk_a_"+model.GenerateUUIDWithSuffix("ref")), + // Only affordable if the first item is ignored. + bulkItem(source, destination, 100, "bulk_b_"+model.GenerateUUIDWithSuffix("ref")), + }, + } + + body, err := request.ToJsonReq(&req) + require.NoError(t, err) + + var preview model.BulkTransactionPreview + resp, err := SetUpTestRequest(TestRequest{ + Payload: body, Response: &preview, Method: http.MethodPost, + Route: "/transactions/bulk", Router: router, + }) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.True(t, preview.Cumulative, "skip_queue batches run in order, so the projection must accumulate") + assert.False(t, preview.WouldApply, "the batch overspends itself and must not be projected as applying") + + require.Len(t, preview.Results, 2) + assert.True(t, preview.Results[0].WouldApply) + assert.False(t, preview.Results[1].WouldApply, "the second item must be judged against the first item's effect") + require.NotNil(t, preview.Results[1].Rejection) + assert.Equal(t, "TXN_INSUFFICIENT_FUNDS", preview.Results[1].Rejection.Code) +} + +// TestDryRunBulkIndependentWhenQueued covers the other half: without skip_queue +// the items are dispatched concurrently, so claiming an order would be wrong. +func TestDryRunBulkIndependentWhenQueued(t *testing.T) { + router, b, err := setupRouter() + require.NoError(t, err) + + source, destination := newDryRunFixture(t, b) + + req := model2.BulkTransactionRequest{ + DryRun: true, + SkipQueue: false, + Transactions: []*model2.RecordTransaction{ + bulkItem(source, destination, 450, "bulkq_a_"+model.GenerateUUIDWithSuffix("ref")), + bulkItem(source, destination, 100, "bulkq_b_"+model.GenerateUUIDWithSuffix("ref")), + }, + } + + body, err := request.ToJsonReq(&req) + require.NoError(t, err) + + var preview model.BulkTransactionPreview + resp, err := SetUpTestRequest(TestRequest{ + Payload: body, Response: &preview, Method: http.MethodPost, + Route: "/transactions/bulk", Router: router, + }) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.False(t, preview.Cumulative) + assert.True(t, preview.WouldApply, "projected independently, each item fits on its own") + require.Len(t, preview.Results, 2) + assert.True(t, preview.Results[0].WouldApply) + assert.True(t, preview.Results[1].WouldApply) + assert.NotEmpty(t, preview.Notes, "the caller must be told the items were not ordered") +} + +// TestDryRunBulkWritesNothing checks the batch projection leaves no trace. +func TestDryRunBulkWritesNothing(t *testing.T) { + router, b, err := setupRouter() + require.NoError(t, err) + + source, destination := newDryRunFixture(t, b) + reference := "bulknone_" + model.GenerateUUIDWithSuffix("ref") + + req := model2.BulkTransactionRequest{ + DryRun: true, + SkipQueue: true, + Transactions: []*model2.RecordTransaction{bulkItem(source, destination, 100, reference)}, + } + + body, err := request.ToJsonReq(&req) + require.NoError(t, err) + + var preview model.BulkTransactionPreview + resp, err := SetUpTestRequest(TestRequest{ + Payload: body, Response: &preview, Method: http.MethodPost, + Route: "/transactions/bulk", Router: router, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code) + + _, err = b.GetTransactionByRef(t.Context(), reference) + assert.Error(t, err, "a bulk dry run must not persist its items") + + after, err := b.GetBalanceByID(t.Context(), source, nil, false) + require.NoError(t, err) + assert.Equal(t, "50000", after.Balance.String(), "a bulk dry run must not move balances") +} + +// TestDryRunBulkAtomicNotesCompensation checks the response says what atomic +// really does, rather than implying a rollback the ledger does not perform. +func TestDryRunBulkAtomicNotesCompensation(t *testing.T) { + router, b, err := setupRouter() + require.NoError(t, err) + + source, destination := newDryRunFixture(t, b) + + req := model2.BulkTransactionRequest{ + DryRun: true, + SkipQueue: true, + Atomic: true, + Transactions: []*model2.RecordTransaction{bulkItem(source, destination, 100, "bulkatomic_"+model.GenerateUUIDWithSuffix("ref"))}, + } + + body, err := request.ToJsonReq(&req) + require.NoError(t, err) + + var preview model.BulkTransactionPreview + _, err = SetUpTestRequest(TestRequest{ + Payload: body, Response: &preview, Method: http.MethodPost, + Route: "/transactions/bulk", Router: router, + }) + require.NoError(t, err) + + assert.True(t, preview.Atomic) + found := false + for _, note := range preview.Notes { + if strings.Contains(note, "compensates") { + found = true + } + } + assert.True(t, found, "an atomic batch must be described as compensating, not rolling back") +} diff --git a/api/transaction_dryrun_inflight_api_test.go b/api/transaction_dryrun_inflight_api_test.go new file mode 100644 index 00000000..b56428b1 --- /dev/null +++ b/api/transaction_dryrun_inflight_api_test.go @@ -0,0 +1,181 @@ +package api + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blnkfinance/blnk/internal/request" + "github.com/blnkfinance/blnk/model" +) + +// TestDryRunInflightCommitProjectsSettlement checks a committed hold is shown +// moving from inflight into the settled balance, without settling it. +func TestDryRunInflightCommitProjectsSettlement(t *testing.T) { + router, b, err := setupRouter() + require.NoError(t, err) + + source, destination := newDryRunFixture(t, b) + + hold, err := b.QueueTransaction(t.Context(), &model.Transaction{ + Reference: "holdcommit_" + model.GenerateUUIDWithSuffix("ref"), + Source: source, + Destination: destination, + Amount: 100, + Precision: 100, + Currency: "USD", + Inflight: true, + SkipQueue: true, + }) + require.NoError(t, err) + + payload, err := request.ToJsonReq(&map[string]interface{}{"dry_run": true, "status": "commit"}) + require.NoError(t, err) + + var preview model.TransactionPreview + resp, err := SetUpTestRequest(TestRequest{ + Payload: payload, Response: &preview, Method: http.MethodPut, + Route: "/transactions/inflight/" + hold.TransactionID, Router: router, + }) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.True(t, preview.DryRun) + assert.Equal(t, "commit", preview.Operation) + assert.True(t, preview.WouldApply) + require.Len(t, preview.Balances, 2) + + // The hold is released and the money actually moves. + assert.Equal(t, "10000", preview.Balances[0].CurrentInflightDebitBalance) + assert.Equal(t, "0", preview.Balances[0].ResultingInflightDebitBalance) + + // And the hold is still open afterwards. + after, err := b.GetTransaction(t.Context(), hold.TransactionID) + require.NoError(t, err) + assert.Equal(t, "INFLIGHT", after.Status, "a dry run must not settle the hold") +} + +// TestDryRunInflightVoidIgnoresAmount pins that a void always releases the +// whole remaining hold: the endpoint has no partial void, so an amount sent +// with one is reported as ignored rather than silently honoured. +func TestDryRunInflightVoidIgnoresAmount(t *testing.T) { + router, b, err := setupRouter() + require.NoError(t, err) + + source, destination := newDryRunFixture(t, b) + + hold, err := b.QueueTransaction(t.Context(), &model.Transaction{ + Reference: "holdvoid_" + model.GenerateUUIDWithSuffix("ref"), + Source: source, + Destination: destination, + Amount: 100, + Precision: 100, + Currency: "USD", + Inflight: true, + SkipQueue: true, + }) + require.NoError(t, err) + + payload, err := request.ToJsonReq(&map[string]interface{}{ + "dry_run": true, "status": "void", "precise_amount": 4000, + }) + require.NoError(t, err) + + var preview model.TransactionPreview + resp, err := SetUpTestRequest(TestRequest{ + Payload: payload, Response: &preview, Method: http.MethodPut, + Route: "/transactions/inflight/" + hold.TransactionID, Router: router, + }) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "void", preview.Operation) + assert.True(t, preview.WouldApply) + + // The full 100.00 hold is released, not the 40.00 that was asked for. + assert.Equal(t, "10000", preview.PreciseAmount) + + found := false + for _, note := range preview.Notes { + if note == "amount is ignored when voiding; a void always releases the full remaining hold" { + found = true + } + } + assert.True(t, found, "the caller must be told the amount was ignored") +} + +// TestDryRunInflightWritesNothing checks the projection leaves the hold and the +// balances exactly as they were. +func TestDryRunInflightWritesNothing(t *testing.T) { + router, b, err := setupRouter() + require.NoError(t, err) + + source, destination := newDryRunFixture(t, b) + + hold, err := b.QueueTransaction(t.Context(), &model.Transaction{ + Reference: "holdnone_" + model.GenerateUUIDWithSuffix("ref"), + Source: source, + Destination: destination, + Amount: 100, + Precision: 100, + Currency: "USD", + Inflight: true, + SkipQueue: true, + }) + require.NoError(t, err) + + before, err := b.GetBalanceByID(t.Context(), source, nil, false) + require.NoError(t, err) + + payload, err := request.ToJsonReq(&map[string]interface{}{"dry_run": true, "status": "commit"}) + require.NoError(t, err) + + var preview model.TransactionPreview + _, err = SetUpTestRequest(TestRequest{ + Payload: payload, Response: &preview, Method: http.MethodPut, + Route: "/transactions/inflight/" + hold.TransactionID, Router: router, + }) + require.NoError(t, err) + + after, err := b.GetBalanceByID(t.Context(), source, nil, false) + require.NoError(t, err) + + assert.Equal(t, before.Balance.String(), after.Balance.String()) + assert.Equal(t, before.InflightDebitBalance.String(), after.InflightDebitBalance.String()) + assert.Equal(t, before.Version, after.Version, "version moves on any persisted balance write") +} + +// TestDryRunInflightRejectsUnknownStatus checks the action is validated the +// same way a real settlement validates it. +func TestDryRunInflightRejectsUnknownStatus(t *testing.T) { + router, b, err := setupRouter() + require.NoError(t, err) + + source, destination := newDryRunFixture(t, b) + + hold, err := b.QueueTransaction(t.Context(), &model.Transaction{ + Reference: "holdbad_" + model.GenerateUUIDWithSuffix("ref"), + Source: source, + Destination: destination, + Amount: 100, + Precision: 100, + Currency: "USD", + Inflight: true, + SkipQueue: true, + }) + require.NoError(t, err) + + payload, err := request.ToJsonReq(&map[string]interface{}{"dry_run": true, "status": "settle"}) + require.NoError(t, err) + + var body map[string]interface{} + resp, err := SetUpTestRequest(TestRequest{ + Payload: payload, Response: &body, Method: http.MethodPut, + Route: "/transactions/inflight/" + hold.TransactionID, Router: router, + }) + require.NoError(t, err) + + assert.Equal(t, http.StatusBadRequest, resp.Code, "an unsupported action is a malformed request, not a projection") +} diff --git a/api/transactions.go b/api/transactions.go index f7f341e3..40ef8543 100644 --- a/api/transactions.go +++ b/api/transactions.go @@ -86,6 +86,49 @@ func transformTransaction(txn *model.Transaction) *model.Transaction { return &result } +// respondPreview writes a dry-run projection. +// +// The status is 200 rather than 201 because nothing was created. A projected +// rejection is still a 200: the request succeeded and the answer is "no". +// +// The rejection carries the same error code a real post would have returned, +// resolved through the classifier every other endpoint uses, so existing +// client-side handling for e.g. TXN_INSUFFICIENT_FUNDS works against a preview +// without change. +func (a Api) respondPreview(c *gin.Context, preview *model.TransactionPreview, err error) { + if err != nil { + respondError(c, err, + withUpgrade(apierror.ErrGenNotFound, apierror.ErrTxnNotFound), + withDefault(apierror.ErrTxnValidation)) + return + } + + resolvePreviewRejectionCode(preview) + c.JSON(http.StatusOK, preview) +} + +// resolvePreviewRejectionCode fills in the error code a real post would have +// returned for a projected rejection, using the same classifier the other +// endpoints resolve errors through. +func resolvePreviewRejectionCode(preview *model.TransactionPreview) { + if preview == nil || preview.Rejection == nil || preview.Rejection.Code != "" { + return + } + + code, ok := classifyMessage(preview.Rejection.Message) + if !ok { + code = apierror.ErrTxnValidation + } + preview.Rejection.Code = string(code) +} + +// resolvePreviewRejectionCodes fills in rejection codes across a batch. +func resolvePreviewRejectionCodes(previews []model.TransactionPreview) { + for i := range previews { + resolvePreviewRejectionCode(&previews[i]) + } +} + func handleRecordTransactionValidationError(c *gin.Context, err error) { var validationErrors validation.Errors if errors.As(err, &validationErrors) { @@ -130,6 +173,14 @@ func (a Api) QueueTransaction(c *gin.Context) { return } + // A dry run answers what the transaction would do and stops there, so it + // never reaches the queue. + if newTransaction.DryRun { + preview, err := a.blnk.PreviewTransaction(c.Request.Context(), newTransaction.ToTransaction()) + a.respondPreview(c, preview, err) + return + } + // Queue the transaction using the Blnk service resp, err := a.blnk.QueueTransaction(c.Request.Context(), newTransaction.ToTransaction()) if err != nil { @@ -151,6 +202,11 @@ type refundTransactionRequest struct { // refunds where the caller needs immediate confirmation. Mirrors the // skip_queue flag on Create Transaction. SkipQueue bool `json:"skip_queue"` + + // DryRun projects the reversal and returns the resulting balances without + // creating the refund. The parent transaction is not marked refunded, so a + // real refund afterwards still succeeds. + DryRun bool `json:"dry_run"` } // RefundTransaction processes a refund for a transaction based on the given ID. @@ -185,6 +241,12 @@ func (a Api) RefundTransaction(c *gin.Context) { return } + if req.DryRun { + preview, err := a.blnk.PreviewRefund(c.Request.Context(), id) + a.respondPreview(c, preview, err) + return + } + transaction, err := a.blnk.ProcessTransactionInBatches(c.Request.Context(), id, big.NewInt(0), 1, false, a.blnk.GetRefundableTransactionsByParentID, a.blnk.RefundWorkerWithOptions(req.SkipQueue)) if err != nil { respondError(c, err, withUpgrade(apierror.ErrGenNotFound, apierror.ErrTxnNotFound), withDefault(apierror.ErrGenBadRequest)) @@ -397,6 +459,20 @@ func (a Api) UpdateInflightStatus(c *gin.Context) { return } + status := req.Status + if status != blnk.InflightActionCommit && status != blnk.InflightActionVoid { + respondCode(c, apierror.ErrTxnInvalidStatusAction, "status not supported. use either commit or void", nil) + return + } + + // Projected before the precision lookup below, which caches into package + // state, and before any queueing. + if req.DryRun { + preview, err := a.blnk.PreviewInflightAction(c.Request.Context(), id, status, req.PreciseAmount) + a.respondPreview(c, preview, err) + return + } + cnf, err := config.Fetch() if err != nil { respondError(c, err) @@ -418,12 +494,6 @@ func (a Api) UpdateInflightStatus(c *gin.Context) { amount = req.PreciseAmount } - status := req.Status - if status != blnk.InflightActionCommit && status != blnk.InflightActionVoid { - respondCode(c, apierror.ErrTxnInvalidStatusAction, "status not supported. use either commit or void", nil) - return - } - // Default: route the action through the inflight-commit queue. The response // is the still-inflight parent plus a queued marker; the worker applies it. if !req.SkipQueue { @@ -513,6 +583,21 @@ func (a Api) CreateBulkTransactions(c *gin.Context) { bulkReq := req.ToBulkTransactionRequest() + // Projected after the per-item validation above, so a dry run is held to + // the same input rules as a real batch, but before anything is dispatched. + if req.DryRun { + preview, err := a.blnk.PreviewBulkTransactions(c.Request.Context(), bulkReq) + if err != nil { + respondError(c, err, + withUpgrade(apierror.ErrGenNotFound, apierror.ErrTxnNotFound), + withDefault(apierror.ErrTxnValidation)) + return + } + resolvePreviewRejectionCodes(preview.Results) + c.JSON(http.StatusOK, preview) + return + } + // Call the service layer method to handle bulk transaction creation result, err := a.blnk.CreateBulkTransactions(c.Request.Context(), bulkReq) // Handle the response based on the result and error from the service layer diff --git a/model/clone.go b/model/clone.go new file mode 100644 index 00000000..904d9060 --- /dev/null +++ b/model/clone.go @@ -0,0 +1,115 @@ +/* +Copyright 2024 Blnk Finance Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package model + +import "math/big" + +// cloneBigInt copies v by value. +// +// nil is preserved rather than promoted to zero so that a clone behaves +// identically to its original under InitializeBalanceFields, which +// distinguishes "unset" from "zero". +func cloneBigInt(v *big.Int) *big.Int { + if v == nil { + return nil + } + return new(big.Int).Set(v) +} + +// Clone returns a deep copy of the balance that a transaction can be applied +// to without affecting the original. +// +// Every *big.Int field is copied by value. This is what makes the copy +// meaningful: big.Int's Add and Sub mutate their receiver, so a plain struct +// copy would share the underlying integers with the original, and applying a +// transaction to the copy would silently rewrite the original's balances too. +// +// Used by the dry-run projection to hold a "before" snapshot alongside the +// balances the transaction is applied to. +func (balance *Balance) Clone() *Balance { + if balance == nil { + return nil + } + + clone := *balance + + clone.Balance = cloneBigInt(balance.Balance) + clone.InflightBalance = cloneBigInt(balance.InflightBalance) + clone.CreditBalance = cloneBigInt(balance.CreditBalance) + clone.InflightCreditBalance = cloneBigInt(balance.InflightCreditBalance) + clone.DebitBalance = cloneBigInt(balance.DebitBalance) + clone.InflightDebitBalance = cloneBigInt(balance.InflightDebitBalance) + clone.QueuedDebitBalance = cloneBigInt(balance.QueuedDebitBalance) + clone.QueuedCreditBalance = cloneBigInt(balance.QueuedCreditBalance) + + if balance.MetaData != nil { + metaData := make(map[string]interface{}, len(balance.MetaData)) + for k, v := range balance.MetaData { + metaData[k] = v + } + clone.MetaData = metaData + } + + return &clone +} + +// Clone returns a deep copy of the transaction, safe to mutate without +// affecting the original. +// +// The balance-applying path mutates the transaction it is given — UpdateBalances +// sets PreciseAmount, and the inflight helpers rewrite Amount — so a caller that +// wants to keep its own transaction intact (notably the dry-run projection, which +// must not disturb a transaction it was only asked to evaluate) applies the +// arithmetic to a clone. +func (transaction *Transaction) Clone() *Transaction { + if transaction == nil { + return nil + } + + clone := *transaction + + clone.PreciseAmount = cloneBigInt(transaction.PreciseAmount) + + if transaction.EffectiveDate != nil { + effectiveDate := *transaction.EffectiveDate + clone.EffectiveDate = &effectiveDate + } + + if transaction.MetaData != nil { + metaData := make(map[string]interface{}, len(transaction.MetaData)) + for k, v := range transaction.MetaData { + metaData[k] = v + } + clone.MetaData = metaData + } + + if transaction.Sources != nil { + clone.Sources = make([]Distribution, len(transaction.Sources)) + copy(clone.Sources, transaction.Sources) + } + + if transaction.Destinations != nil { + clone.Destinations = make([]Distribution, len(transaction.Destinations)) + copy(clone.Destinations, transaction.Destinations) + } + + if transaction.GroupIds != nil { + clone.GroupIds = make([]string, len(transaction.GroupIds)) + copy(clone.GroupIds, transaction.GroupIds) + } + + return &clone +} diff --git a/model/clone_test.go b/model/clone_test.go new file mode 100644 index 00000000..498d6674 --- /dev/null +++ b/model/clone_test.go @@ -0,0 +1,193 @@ +package model + +import ( + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newFullBalance builds a balance with every *big.Int field populated with a +// distinct value, so an aliasing bug on any single field is detectable. +func newFullBalance() *Balance { + return &Balance{ + BalanceID: "bln_clone_test", + Currency: "USD", + LedgerID: "ldg_1", + Version: 7, + Balance: big.NewInt(1000), + InflightBalance: big.NewInt(2000), + CreditBalance: big.NewInt(3000), + InflightCreditBalance: big.NewInt(4000), + DebitBalance: big.NewInt(5000), + InflightDebitBalance: big.NewInt(6000), + QueuedDebitBalance: big.NewInt(7000), + QueuedCreditBalance: big.NewInt(8000), + MetaData: map[string]interface{}{"owner": "acme"}, + } +} + +// TestBalanceCloneDoesNotAlias is the tripwire for the whole dry-run feature: +// big.Int's Add/Sub mutate their receiver, so if Clone shared any *big.Int +// with the original, applying a transaction to the clone would rewrite the +// original and a "before" snapshot would silently show "after" values. +func TestBalanceCloneDoesNotAlias(t *testing.T) { + original := newFullBalance() + clone := original.Clone() + + fields := []struct { + name string + original, copy func(*Balance) *big.Int + }{ + {"Balance", func(b *Balance) *big.Int { return b.Balance }, func(b *Balance) *big.Int { return b.Balance }}, + {"InflightBalance", func(b *Balance) *big.Int { return b.InflightBalance }, func(b *Balance) *big.Int { return b.InflightBalance }}, + {"CreditBalance", func(b *Balance) *big.Int { return b.CreditBalance }, func(b *Balance) *big.Int { return b.CreditBalance }}, + {"InflightCreditBalance", func(b *Balance) *big.Int { return b.InflightCreditBalance }, func(b *Balance) *big.Int { return b.InflightCreditBalance }}, + {"DebitBalance", func(b *Balance) *big.Int { return b.DebitBalance }, func(b *Balance) *big.Int { return b.DebitBalance }}, + {"InflightDebitBalance", func(b *Balance) *big.Int { return b.InflightDebitBalance }, func(b *Balance) *big.Int { return b.InflightDebitBalance }}, + {"QueuedDebitBalance", func(b *Balance) *big.Int { return b.QueuedDebitBalance }, func(b *Balance) *big.Int { return b.QueuedDebitBalance }}, + {"QueuedCreditBalance", func(b *Balance) *big.Int { return b.QueuedCreditBalance }, func(b *Balance) *big.Int { return b.QueuedCreditBalance }}, + } + + for _, f := range fields { + assert.NotSame(t, f.original(original), f.copy(clone), "%s must not be shared with the original", f.name) + } + + // Mutating every field on the clone the way the apply path does must leave + // the original untouched. + snapshot := newFullBalance() + clone.addDebit(big.NewInt(500), false) + clone.addDebit(big.NewInt(500), true) + clone.addCredit(big.NewInt(500), false) + clone.addCredit(big.NewInt(500), true) + clone.computeBalance(false) + clone.computeBalance(true) + clone.QueuedDebitBalance.Add(clone.QueuedDebitBalance, big.NewInt(500)) + clone.QueuedCreditBalance.Add(clone.QueuedCreditBalance, big.NewInt(500)) + + assert.Equal(t, snapshot.Balance, original.Balance) + assert.Equal(t, snapshot.InflightBalance, original.InflightBalance) + assert.Equal(t, snapshot.CreditBalance, original.CreditBalance) + assert.Equal(t, snapshot.InflightCreditBalance, original.InflightCreditBalance) + assert.Equal(t, snapshot.DebitBalance, original.DebitBalance) + assert.Equal(t, snapshot.InflightDebitBalance, original.InflightDebitBalance) + assert.Equal(t, snapshot.QueuedDebitBalance, original.QueuedDebitBalance) + assert.Equal(t, snapshot.QueuedCreditBalance, original.QueuedCreditBalance) +} + +func TestBalanceCloneCopiesScalarsAndMetaData(t *testing.T) { + original := newFullBalance() + clone := original.Clone() + + assert.Equal(t, original.BalanceID, clone.BalanceID) + assert.Equal(t, original.Currency, clone.Currency) + assert.Equal(t, original.Version, clone.Version) + + clone.MetaData["owner"] = "changed" + assert.Equal(t, "acme", original.MetaData["owner"], "MetaData must not be shared with the original") +} + +// TestBalanceClonePreservesNil guards the distinction InitializeBalanceFields +// relies on: an unset *big.Int must stay nil rather than becoming zero. +func TestBalanceClonePreservesNil(t *testing.T) { + original := &Balance{BalanceID: "bln_sparse"} + clone := original.Clone() + require.NotNil(t, clone) + + assert.Nil(t, clone.Balance) + assert.Nil(t, clone.InflightBalance) + assert.Nil(t, clone.CreditBalance) + assert.Nil(t, clone.InflightCreditBalance) + assert.Nil(t, clone.DebitBalance) + assert.Nil(t, clone.InflightDebitBalance) + assert.Nil(t, clone.QueuedDebitBalance) + assert.Nil(t, clone.QueuedCreditBalance) + assert.Nil(t, clone.MetaData) + + // A cloned sparse balance must still initialize exactly like the original. + clone.InitializeBalanceFields() + assert.Equal(t, big.NewInt(0), clone.Balance) + assert.Nil(t, original.Balance, "initializing the clone must not touch the original") +} + +func TestBalanceCloneNil(t *testing.T) { + var balance *Balance + assert.Nil(t, balance.Clone()) +} + +// TestTransactionCloneDoesNotAlias covers the second half of the projection's +// safety: UpdateBalances writes PreciseAmount onto the transaction it is given. +func TestTransactionCloneDoesNotAlias(t *testing.T) { + effectiveDate := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + original := &Transaction{ + TransactionID: "txn_clone_test", + Amount: 100, + Precision: 100, + PreciseAmount: big.NewInt(10000), + Currency: "USD", + EffectiveDate: &effectiveDate, + MetaData: map[string]interface{}{"reason": "goodwill"}, + Sources: []Distribution{{Identifier: "@Revenue", Distribution: "60%"}}, + Destinations: []Distribution{{Identifier: "bln_1", Distribution: "left"}}, + GroupIds: []string{"grp_1"}, + } + + clone := original.Clone() + + assert.NotSame(t, original.PreciseAmount, clone.PreciseAmount) + assert.NotSame(t, original.EffectiveDate, clone.EffectiveDate) + + clone.PreciseAmount.Add(clone.PreciseAmount, big.NewInt(1)) + clone.Amount = 999 + clone.MetaData["reason"] = "changed" + clone.Sources[0].Identifier = "@Changed" + clone.Destinations[0].Identifier = "bln_changed" + clone.GroupIds[0] = "grp_changed" + *clone.EffectiveDate = clone.EffectiveDate.Add(time.Hour) + + assert.Equal(t, big.NewInt(10000), original.PreciseAmount) + assert.Equal(t, float64(100), original.Amount) + assert.Equal(t, "goodwill", original.MetaData["reason"]) + assert.Equal(t, "@Revenue", original.Sources[0].Identifier) + assert.Equal(t, "bln_1", original.Destinations[0].Identifier) + assert.Equal(t, "grp_1", original.GroupIds[0]) + assert.Equal(t, effectiveDate, *original.EffectiveDate) +} + +func TestTransactionClonePreservesNil(t *testing.T) { + original := &Transaction{TransactionID: "txn_sparse"} + clone := original.Clone() + require.NotNil(t, clone) + + assert.Nil(t, clone.PreciseAmount) + assert.Nil(t, clone.EffectiveDate) + assert.Nil(t, clone.MetaData) + assert.Nil(t, clone.Sources) + assert.Nil(t, clone.Destinations) + assert.Nil(t, clone.GroupIds) +} + +func TestTransactionCloneNil(t *testing.T) { + var transaction *Transaction + assert.Nil(t, transaction.Clone()) +} + +// TestCloneSurvivesUpdateBalances is the end-to-end statement of why Clone +// exists: applying a real transaction to cloned balances must leave the +// originals — the "before" snapshot — completely untouched. +func TestCloneSurvivesUpdateBalances(t *testing.T) { + source := &Balance{BalanceID: "bln_src", Currency: "USD", Balance: big.NewInt(50000), CreditBalance: big.NewInt(50000), DebitBalance: big.NewInt(0)} + destination := &Balance{BalanceID: "bln_dst", Currency: "USD", Balance: big.NewInt(0), CreditBalance: big.NewInt(0), DebitBalance: big.NewInt(0)} + + sourceSnapshot, destinationSnapshot := source.Clone(), destination.Clone() + + transaction := &Transaction{Amount: 100, Precision: 100, Currency: "USD"} + require.NoError(t, UpdateBalances(transaction.Clone(), source.Clone(), destination.Clone())) + + assert.Equal(t, sourceSnapshot.Balance, source.Balance) + assert.Equal(t, sourceSnapshot.DebitBalance, source.DebitBalance) + assert.Equal(t, destinationSnapshot.Balance, destination.Balance) + assert.Equal(t, destinationSnapshot.CreditBalance, destination.CreditBalance) +} diff --git a/model/transaction_preview.go b/model/transaction_preview.go new file mode 100644 index 00000000..ede96da4 --- /dev/null +++ b/model/transaction_preview.go @@ -0,0 +1,138 @@ +/* +Copyright 2024 Blnk Finance Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package model + +// Balance roles reported by a projection. +const ( + PreviewRoleSource = "source" + PreviewRoleDestination = "destination" +) + +// TransactionPreview is the projected effect of a transaction that was +// evaluated but never applied. +// +// Monetary values are strings in minor units, matching precise_amount, so no +// precision is lost in transit. +type TransactionPreview struct { + DryRun bool `json:"dry_run"` + + // WouldApply reports whether a real post of this transaction would be + // accepted against the balances as they currently stand. When false, + // Rejection carries the reason. + WouldApply bool `json:"would_apply"` + Rejection *PreviewRejection `json:"rejection,omitempty"` + + // Operation names the settlement being projected on the inflight endpoint: + // "commit" or "void". Empty for ordinary transaction projections. + Operation string `json:"operation,omitempty"` + Status string `json:"status"` + Reference string `json:"reference,omitempty"` + Currency string `json:"currency"` + Amount float64 `json:"amount"` + PreciseAmount string `json:"precise_amount"` + Precision float64 `json:"precision"` + Balances []BalanceProjection `json:"balances"` + + // Legs is populated for multi-source/destination transactions: one entry + // per split, using the same distribution math a real post would use. + Legs []LegProjection `json:"legs,omitempty"` + + // Notes carry advisory information that is not a rejection — an ignored + // field, or a condition worth surfacing before the caller posts for real. + Notes []string `json:"notes,omitempty"` +} + +// PreviewRejection describes why a projected transaction would not apply. +// +// Code is the same error code a real post would return, so existing +// client-side handling for e.g. TXN_INSUFFICIENT_FUNDS works against a +// preview unchanged. +type PreviewRejection struct { + Code string `json:"code"` + Reason string `json:"reason"` + Message string `json:"message"` +} + +// BalanceProjection is one balance's state before and after the projected +// transaction. Current* is the snapshot the projection started from; +// Resulting* is that snapshot with the transaction applied in memory. +type BalanceProjection struct { + BalanceID string `json:"balance_id"` + Role string `json:"role"` + Currency string `json:"currency"` + + // Virtual marks a balance that does not exist yet — an @indicator that a + // real post would create on demand. It is projected against zero and is + // not created by the preview. + Virtual bool `json:"virtual,omitempty"` + + CurrentBalance string `json:"current_balance"` + CurrentAvailable string `json:"current_available"` + CurrentCreditBalance string `json:"current_credit_balance"` + CurrentDebitBalance string `json:"current_debit_balance"` + CurrentInflightDebitBalance string `json:"current_inflight_debit_balance"` + CurrentInflightCreditBalance string `json:"current_inflight_credit_balance"` + + ResultingBalance string `json:"resulting_balance"` + ResultingAvailable string `json:"resulting_available"` + ResultingCreditBalance string `json:"resulting_credit_balance"` + ResultingDebitBalance string `json:"resulting_debit_balance"` + ResultingInflightDebitBalance string `json:"resulting_inflight_debit_balance"` + ResultingInflightCreditBalance string `json:"resulting_inflight_credit_balance"` +} + +// LegProjection is one split of a multi-source/destination transaction. +type LegProjection struct { + Identifier string `json:"identifier"` + Role string `json:"role"` + PreciseAmount string `json:"precise_amount"` + Amount float64 `json:"amount"` +} + +// AddNote appends an advisory note to the projection. +func (preview *TransactionPreview) AddNote(note string) { + preview.Notes = append(preview.Notes, note) +} + +// BulkTransactionPreview is the projected effect of a batch that was evaluated +// but never applied. +type BulkTransactionPreview struct { + DryRun bool `json:"dry_run"` + + // WouldApply is false when any item in the batch would be rejected. + WouldApply bool `json:"would_apply"` + + // Cumulative reports whether items were projected against each other's + // effects. That mirrors how the batch would really run: items are applied + // one after another only when skip_queue is set, and are otherwise + // dispatched concurrently with no guaranteed order. + Cumulative bool `json:"cumulative"` + Atomic bool `json:"atomic"` + + Results []TransactionPreview `json:"results"` + + // Balances is the batch's combined effect per balance, and is reported only + // in cumulative mode — without a guaranteed order there is no single + // combined outcome to state. + Balances []BalanceProjection `json:"balances,omitempty"` + + Notes []string `json:"notes,omitempty"` +} + +// AddNote appends an advisory note to the batch projection. +func (preview *BulkTransactionPreview) AddNote(note string) { + preview.Notes = append(preview.Notes, note) +} diff --git a/transaction_dryrun.go b/transaction_dryrun.go new file mode 100644 index 00000000..dfb257aa --- /dev/null +++ b/transaction_dryrun.go @@ -0,0 +1,469 @@ +/* +Copyright 2024 Blnk Finance Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package blnk + +import ( + "context" + "fmt" + "math/big" + "strings" + + "github.com/blnkfinance/blnk/model" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +// PreviewTransaction projects the effect of a transaction on its source and +// destination balances and returns the result without applying it. +// +// The projection runs the same arithmetic a real post runs — processBalances, +// and through it UpdateBalances and canProcessTransaction — against deep copies +// of the balances, so a preview cannot drift from enforcement as the posting +// path evolves. Nothing on this path changes ledger state: no transaction row, +// no balance update, no queue entry, no webhook, no hook, and the reference is +// not consumed. +// +// The answer is advisory. It describes the balances as they stand now, and +// another transaction may change them before the caller posts for real. Callers +// that need the funds held should use inflight instead. +func (l *Blnk) PreviewTransaction(ctx context.Context, transaction *model.Transaction) (*model.TransactionPreview, error) { + ctx, span := tracer.Start(ctx, "PreviewTransaction") + defer span.End() + + if transaction == nil { + return nil, fmt.Errorf("transaction is required") + } + + // The arithmetic below writes PreciseAmount onto the transaction, so work + // from a copy and leave the caller's object untouched. + projected := transaction.Clone() + normalizePreviewStatus(projected) + projected.PreciseAmount = model.ApplyPrecision(projected) + + if len(projected.Sources) > 0 || len(projected.Destinations) > 0 { + return l.previewSplitTransaction(ctx, projected) + } + + preview, err := l.previewSingleTransaction(ctx, projected) + if err != nil { + span.RecordError(err) + return nil, err + } + + span.SetAttributes(attribute.Bool("preview.would_apply", preview.WouldApply)) + return preview, nil +} + +// PreviewRefund projects the reversal of an existing transaction without +// creating it. +// +// It runs the same lookup and eligibility checks a real refund runs, and builds +// the reversal with the same helper, so a projection that reports the refund as +// applicable is one the ledger would accept. Nothing is written: the refund is +// never queued and the parent is not marked refunded. +func (l *Blnk) PreviewRefund(ctx context.Context, transactionID string) (*model.TransactionPreview, error) { + ctx, span := tracer.Start(ctx, "PreviewRefund") + defer span.End() + + originalTxn, err := l.getOriginalTransactionForRefund(ctx, transactionID) + if err != nil { + span.RecordError(err) + return nil, err + } + + if err := l.validateTransactionForRefund(ctx, originalTxn); err != nil { + span.RecordError(err) + return nil, err + } + + // Same builder the real refund uses: source and destination swapped, + // overdraft allowed, status reset. skipQueue is irrelevant here because the + // projection never reaches the queue. + refund := prepareRefundTransaction(originalTxn, true) + + preview, err := l.PreviewTransaction(ctx, refund) + if err != nil { + span.RecordError(err) + return nil, err + } + + preview.AddNote(fmt.Sprintf("projected refund of transaction %s", originalTxn.TransactionID)) + return preview, nil +} + +// normalizePreviewStatus assigns the status the transaction would carry by the +// time balances are applied. +// +// A real create picks this up from setTransactionStatus and updateTransactionDetails +// as it passes through the queue; a preview bypasses both, so an unset status +// would otherwise reach the apply path empty. +func normalizePreviewStatus(transaction *model.Transaction) { + switch transaction.Status { + case StatusCommit, StatusVoid: + // Inflight settlement statuses select a different apply branch and are + // set deliberately by the caller. + return + } + + if transaction.Inflight { + transaction.Status = StatusInflight + return + } + transaction.Status = StatusApplied +} + +// previewSingleTransaction projects one source-to-destination movement. +func (l *Blnk) previewSingleTransaction(ctx context.Context, transaction *model.Transaction) (*model.TransactionPreview, error) { + ctx, span := tracer.Start(ctx, "PreviewSingleTransaction") + defer span.End() + + source, destination, err := l.resolveBalancesForPreview(ctx, transaction) + if err != nil { + span.RecordError(err) + return nil, err + } + + // Hold the same locks a real post would, so both balances are read as of a + // single consistent moment rather than torn across two reads. Acquired + // directly rather than through executeWithLock: a preview must not record + // hot-pair contention and steer hot-lane routing for real traffic. + locker, err := l.acquireLock(ctx, source.balance.BalanceID, destination.balance.BalanceID) + if err != nil { + span.RecordError(err) + return nil, fmt.Errorf("failed to acquire lock: %w", err) + } + defer l.releaseLock(ctx, locker) + + preview := newPreviewFor(transaction) + l.notePreviewCaveats(ctx, preview, transaction, source, destination) + + // Snapshot before applying: processBalances mutates what it is given. + sourceBefore, destinationBefore := source.balance.Clone(), destination.balance.Clone() + + applyErr := l.processBalances(ctx, transaction, source.balance, destination.balance) + if applyErr != nil { + preview.WouldApply = false + preview.Rejection = previewRejection(applyErr) + } else { + preview.WouldApply = true + } + + // On rejection the balances hold whatever partial state the apply path left + // behind, which is not a meaningful projection — report the unchanged + // snapshot as the outcome instead. + sourceAfter, destinationAfter := source.balance, destination.balance + if applyErr != nil { + sourceAfter, destinationAfter = sourceBefore, destinationBefore + } + + preview.PreciseAmount = preciseString(transaction.PreciseAmount) + preview.Amount = transaction.Amount + preview.Balances = []model.BalanceProjection{ + balanceProjection(model.PreviewRoleSource, sourceBefore, sourceAfter, source.virtual), + balanceProjection(model.PreviewRoleDestination, destinationBefore, destinationAfter, destination.virtual), + } + + span.AddEvent("Transaction projected", trace.WithAttributes( + attribute.Bool("preview.would_apply", preview.WouldApply), + )) + return preview, nil +} + +// previewSplitTransaction projects a multi-source or multi-destination +// transaction, one entry per split. +// +// A real split records each leg through its own lock-and-apply cycle, so later +// legs observe earlier legs' effects. The projection mirrors that by carrying +// each balance's working copy forward across legs. +func (l *Blnk) previewSplitTransaction(ctx context.Context, transaction *model.Transaction) (*model.TransactionPreview, error) { + ctx, span := tracer.Start(ctx, "PreviewSplitTransaction") + defer span.End() + + legs, err := transaction.SplitTransactionPrecise(ctx) + if err != nil { + span.RecordError(err) + return nil, fmt.Errorf("failed to split transaction: %w", err) + } + + preview := newPreviewFor(transaction) + preview.WouldApply = true + preview.PreciseAmount = preciseString(transaction.PreciseAmount) + preview.Amount = transaction.Amount + + // Working copies shared across legs, so a balance touched twice accumulates + // exactly as it would in a real sequential apply. + working := newPreviewBalanceSet(l) + + for _, leg := range legs { + normalizePreviewStatus(leg) + leg.PreciseAmount = model.ApplyPrecision(leg) + + source, destination, err := working.resolvePair(ctx, leg) + if err != nil { + span.RecordError(err) + return nil, err + } + + if applyErr := l.processBalances(ctx, leg, source.balance, destination.balance); applyErr != nil { + preview.WouldApply = false + if preview.Rejection == nil { + preview.Rejection = previewRejection(applyErr) + } + } + + role, identifier := model.PreviewRoleDestination, leg.Destination + if len(transaction.Sources) > 0 { + role, identifier = model.PreviewRoleSource, leg.Source + } + preview.Legs = append(preview.Legs, model.LegProjection{ + Identifier: identifier, + Role: role, + PreciseAmount: preciseString(leg.PreciseAmount), + Amount: leg.Amount, + }) + } + + preview.Balances = working.projections() + return preview, nil +} + +// previewBalance is a balance resolved for projection, along with whether it +// had to be invented because it does not exist yet. +type previewBalance struct { + balance *model.Balance + before *model.Balance + virtual bool + role string +} + +// resolveBalancesForPreview loads the source and destination balances without +// creating anything. +// +// A real post resolves @indicators through getOrCreateBalanceByIndicator, which +// creates the balance when it is missing. A preview must not, so an unknown +// indicator is projected against a zeroed stand-in and reported as virtual. +func (l *Blnk) resolveBalancesForPreview(ctx context.Context, transaction *model.Transaction) (*previewBalance, *previewBalance, error) { + source, err := l.resolveBalanceForPreview(ctx, transaction.Source, transaction.Currency, model.PreviewRoleSource) + if err != nil { + return nil, nil, err + } + + destination, err := l.resolveBalanceForPreview(ctx, transaction.Destination, transaction.Currency, model.PreviewRoleDestination) + if err != nil { + return nil, nil, err + } + + return source, destination, nil +} + +func (l *Blnk) resolveBalanceForPreview(ctx context.Context, identifier, currency, role string) (*previewBalance, error) { + _, span := tracer.Start(ctx, "ResolveBalanceForPreview") + defer span.End() + + if identifier == "" { + return nil, fmt.Errorf("%s is required", role) + } + + if strings.HasPrefix(identifier, "@") { + balance, err := l.datasource.GetBalanceByIndicator(identifier, currency) + if err != nil { + // The indicator has no balance yet. A real post would create one + // and start it at zero, so project against that rather than + // creating it here. + return &previewBalance{ + balance: &model.Balance{ + BalanceID: identifier, + Indicator: identifier, + Currency: currency, + LedgerID: GeneralLedgerID, + }, + virtual: true, + role: role, + }, nil + } + return &previewBalance{balance: balance, role: role}, nil + } + + balance, err := l.fetchBalanceForPreview(identifier) + if err != nil { + span.RecordError(err) + return nil, err + } + return &previewBalance{balance: balance, role: role}, nil +} + +// fetchBalanceForPreview mirrors the fetch getSourceAndDestination performs, +// including the queued-checks variant. +// +// This has to match: queued debits are only loaded by the withQueued fetch, and +// canProcessTransaction subtracts them only when present. Reading the lighter +// row while the deployment enforces queued checks would let a preview approve a +// transaction the real post then rejects. +func (l *Blnk) fetchBalanceForPreview(balanceID string) (*model.Balance, error) { + if l.config != nil && l.config.Transaction.EnableQueuedChecks { + return l.datasource.GetBalanceByID(balanceID, []string{}, true) + } + return l.datasource.GetBalanceByIDLite(balanceID) +} + +// previewBalanceSet caches balances across the legs of a split so that a +// balance touched more than once accumulates, and so each distinct balance is +// read once. +type previewBalanceSet struct { + blnk *Blnk + order []string + byID map[string]*previewBalance +} + +func newPreviewBalanceSet(l *Blnk) *previewBalanceSet { + return &previewBalanceSet{blnk: l, byID: make(map[string]*previewBalance)} +} + +func (s *previewBalanceSet) resolvePair(ctx context.Context, transaction *model.Transaction) (*previewBalance, *previewBalance, error) { + source, err := s.resolve(ctx, transaction.Source, transaction.Currency, model.PreviewRoleSource) + if err != nil { + return nil, nil, err + } + destination, err := s.resolve(ctx, transaction.Destination, transaction.Currency, model.PreviewRoleDestination) + if err != nil { + return nil, nil, err + } + return source, destination, nil +} + +func (s *previewBalanceSet) resolve(ctx context.Context, identifier, currency, role string) (*previewBalance, error) { + if existing, ok := s.byID[identifier]; ok { + return existing, nil + } + + resolved, err := s.blnk.resolveBalanceForPreview(ctx, identifier, currency, role) + if err != nil { + return nil, err + } + resolved.before = resolved.balance.Clone() + + s.byID[identifier] = resolved + s.order = append(s.order, identifier) + return resolved, nil +} + +func (s *previewBalanceSet) projections() []model.BalanceProjection { + projections := make([]model.BalanceProjection, 0, len(s.order)) + for _, id := range s.order { + entry := s.byID[id] + projections = append(projections, balanceProjection(entry.role, entry.before, entry.balance, entry.virtual)) + } + return projections +} + +// newPreviewFor builds the projection shell shared by every preview shape. +func newPreviewFor(transaction *model.Transaction) *model.TransactionPreview { + return &model.TransactionPreview{ + DryRun: true, + Status: transaction.Status, + Reference: transaction.Reference, + Currency: transaction.Currency, + Precision: transaction.Precision, + } +} + +// notePreviewCaveats records conditions worth surfacing that are not rejections. +func (l *Blnk) notePreviewCaveats(ctx context.Context, preview *model.TransactionPreview, transaction *model.Transaction, source, destination *previewBalance) { + if !transaction.ScheduledFor.IsZero() { + preview.AddNote("scheduled_for is ignored in a dry run; the projection shows the effect as if applied now") + } + + // The apply path does not compare a transaction's currency against its + // balances, so a mismatch is projected rather than rejected — but it is + // almost always a mistake, and better seen here than after posting. + for _, resolved := range []*previewBalance{source, destination} { + if resolved.virtual || resolved.balance.Currency == "" || transaction.Currency == "" { + continue + } + if resolved.balance.Currency != transaction.Currency { + preview.AddNote(fmt.Sprintf( + "currency mismatch: transaction is %s but %s balance %s is %s; the ledger applies this as raw minor units", + transaction.Currency, resolved.role, resolved.balance.BalanceID, resolved.balance.Currency, + )) + } + } + + if transaction.Reference != "" { + if exists, err := l.datasource.TransactionExistsByRef(ctx, transaction.Reference); err == nil && exists { + preview.AddNote("reference is already in use; a real post with this reference would be rejected") + } + } +} + +// previewRejection converts an apply-path error into the projected rejection, +// reusing the same reason vocabulary a real rejection is recorded under. +// +// Code is left for the API layer to fill in: the message-to-code table lives +// there alongside the classifier every other endpoint uses, and duplicating it +// here would let the two drift. +func previewRejection(err error) *model.PreviewRejection { + message := err.Error() + return &model.PreviewRejection{ + Reason: categorizeRejectionReason(message), + Message: message, + } +} + +// balanceProjection renders one balance's before and after state. +func balanceProjection(role string, before, after *model.Balance, virtual bool) model.BalanceProjection { + before, after = before.Clone(), after.Clone() + before.InitializeBalanceFields() + after.InitializeBalanceFields() + + return model.BalanceProjection{ + BalanceID: before.BalanceID, + Role: role, + Currency: before.Currency, + Virtual: virtual, + + CurrentBalance: preciseString(before.Balance), + CurrentAvailable: preciseString(availableBalance(before)), + CurrentCreditBalance: preciseString(before.CreditBalance), + CurrentDebitBalance: preciseString(before.DebitBalance), + CurrentInflightDebitBalance: preciseString(before.InflightDebitBalance), + CurrentInflightCreditBalance: preciseString(before.InflightCreditBalance), + + ResultingBalance: preciseString(after.Balance), + ResultingAvailable: preciseString(availableBalance(after)), + ResultingCreditBalance: preciseString(after.CreditBalance), + ResultingDebitBalance: preciseString(after.DebitBalance), + ResultingInflightDebitBalance: preciseString(after.InflightDebitBalance), + ResultingInflightCreditBalance: preciseString(after.InflightCreditBalance), + } +} + +// availableBalance computes spendable funds the way canProcessTransaction does, +// so the figure shown matches the one enforcement will use. +func availableBalance(balance *model.Balance) *big.Int { + available := new(big.Int).Sub(balance.Balance, balance.InflightDebitBalance) + if balance.QueuedDebitBalance != nil { + available = new(big.Int).Sub(available, balance.QueuedDebitBalance) + } + return available +} + +func preciseString(value *big.Int) string { + if value == nil { + return "0" + } + return value.String() +} diff --git a/transaction_dryrun_bulk.go b/transaction_dryrun_bulk.go new file mode 100644 index 00000000..0d6dbb6f --- /dev/null +++ b/transaction_dryrun_bulk.go @@ -0,0 +1,149 @@ +/* +Copyright 2024 Blnk Finance Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package blnk + +import ( + "context" + + "github.com/blnkfinance/blnk/model" + "go.opentelemetry.io/otel/attribute" +) + +// PreviewBulkTransactions projects a batch without applying any of it. +// +// How the batch would really execute decides how it is projected: +// +// skip_queue=true items are applied inline, one after another, so a later +// item sees what earlier ones did. The projection carries +// balances forward across items, which is what catches a +// batch that spends within itself more than it has. +// +// skip_queue=false each item is handed to its own goroutine and the batch +// loop moves on, so the items race and no order is +// guaranteed. Each item is projected independently against +// the balances as they stand. +// +// The mode is reported as `cumulative` so a caller can tell which answer they +// were given. +func (l *Blnk) PreviewBulkTransactions(ctx context.Context, request *model.BulkTransactionRequest) (*model.BulkTransactionPreview, error) { + ctx, span := tracer.Start(ctx, "PreviewBulkTransactions") + defer span.End() + + preview := &model.BulkTransactionPreview{ + DryRun: true, + WouldApply: true, + Cumulative: request.SkipQueue, + Atomic: request.Atomic, + Results: make([]model.TransactionPreview, 0, len(request.Transactions)), + } + + if !request.SkipQueue { + preview.AddNote("items are dispatched concurrently unless skip_queue is set, so each item is projected independently against current balances and real execution order is not guaranteed") + } + if request.Atomic { + preview.AddNote("an atomic batch compensates on failure by voiding or refunding already-applied items rather than rolling back, and that compensation can itself fail") + } + if request.RunAsync { + preview.AddNote("run_async is ignored in a dry run; the projection is returned immediately") + } + + // One working copy per balance, shared across items in cumulative mode so a + // balance touched twice accumulates, and reused in either mode so each + // distinct balance is read once rather than once per item. + working := newPreviewBalanceSet(l) + + for _, transaction := range request.Transactions { + if transaction == nil { + continue + } + + item := transaction.Clone() + item.Inflight = request.Inflight + item.SkipQueue = request.SkipQueue + + itemPreview, err := l.previewBulkItem(ctx, item, working, request.SkipQueue) + if err != nil { + span.RecordError(err) + return nil, err + } + + if !itemPreview.WouldApply { + preview.WouldApply = false + } + preview.Results = append(preview.Results, *itemPreview) + } + + if request.SkipQueue { + preview.Balances = working.projections() + } + + span.SetAttributes( + attribute.Bool("preview.would_apply", preview.WouldApply), + attribute.Bool("preview.cumulative", preview.Cumulative), + attribute.Int("preview.items", len(preview.Results)), + ) + return preview, nil +} + +// previewBulkItem projects one item of a batch. +// +// In cumulative mode the item is applied to the shared working balances so the +// next item sees it. Otherwise the item is projected on its own, which is what +// a concurrently dispatched item would actually see. +func (l *Blnk) previewBulkItem(ctx context.Context, item *model.Transaction, working *previewBalanceSet, cumulative bool) (*model.TransactionPreview, error) { + if !cumulative { + return l.PreviewTransaction(ctx, item) + } + + normalizePreviewStatus(item) + item.PreciseAmount = model.ApplyPrecision(item) + + // An item may itself be a split, so the two-level shape (batch, item, legs) + // has to be expanded before balances are touched. + legs := []*model.Transaction{item} + if len(item.Sources) > 0 || len(item.Destinations) > 0 { + split, err := item.SplitTransactionPrecise(ctx) + if err != nil { + return nil, err + } + legs = split + } + + preview := newPreviewFor(item) + preview.WouldApply = true + preview.PreciseAmount = preciseString(item.PreciseAmount) + preview.Amount = item.Amount + + for _, leg := range legs { + normalizePreviewStatus(leg) + leg.PreciseAmount = model.ApplyPrecision(leg) + + source, destination, err := working.resolvePair(ctx, leg) + if err != nil { + return nil, err + } + + if applyErr := l.processBalances(ctx, leg, source.balance, destination.balance); applyErr != nil { + preview.WouldApply = false + if preview.Rejection == nil { + preview.Rejection = previewRejection(applyErr) + } + } + } + + return preview, nil +} diff --git a/transaction_dryrun_inflight.go b/transaction_dryrun_inflight.go new file mode 100644 index 00000000..0b8e6ea0 --- /dev/null +++ b/transaction_dryrun_inflight.go @@ -0,0 +1,174 @@ +/* +Copyright 2024 Blnk Finance Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package blnk + +import ( + "context" + "fmt" + "math/big" + + "github.com/blnkfinance/blnk/model" + "go.opentelemetry.io/otel/attribute" +) + +// PreviewInflightAction projects a commit or void of an inflight transaction +// without settling it. +// +// txID may name a single inflight transaction or the parent of several legs. A +// parent is expanded and every leg is projected, so the caller sees the whole +// balance effect rather than one representative leg's. +// +// Nothing is written: no settlement transaction is recorded, no hold is +// released, and nothing is queued. +func (l *Blnk) PreviewInflightAction(ctx context.Context, txID, action string, amount *big.Int) (*model.TransactionPreview, error) { + ctx, span := tracer.Start(ctx, "PreviewInflightAction") + defer span.End() + + if action != InflightActionCommit && action != InflightActionVoid { + return nil, fmt.Errorf("status not supported. use either commit or void") + } + + legs, err := l.inflightLegsForPreview(ctx, txID) + if err != nil { + span.RecordError(err) + return nil, err + } + + preview := &model.TransactionPreview{ + DryRun: true, + WouldApply: true, + Operation: action, + } + + // Void always releases the whole remaining hold, so an amount sent with one + // would be silently ignored rather than honoured — say so instead. + if action == InflightActionVoid && amount != nil && amount.Sign() > 0 { + preview.AddNote("amount is ignored when voiding; a void always releases the full remaining hold") + } + + working := newPreviewBalanceSet(l) + total := big.NewInt(0) + + for _, leg := range legs { + settlement, err := l.buildInflightSettlementForPreview(ctx, leg, action, amount) + if err != nil { + preview.WouldApply = false + if preview.Rejection == nil { + preview.Rejection = previewRejection(err) + } + continue + } + + source, destination, err := working.resolvePair(ctx, settlement) + if err != nil { + span.RecordError(err) + return nil, err + } + + if applyErr := l.processBalances(ctx, settlement, source.balance, destination.balance); applyErr != nil { + preview.WouldApply = false + if preview.Rejection == nil { + preview.Rejection = previewRejection(applyErr) + } + continue + } + + total = new(big.Int).Add(total, settlement.PreciseAmount) + preview.Currency = settlement.Currency + preview.Precision = settlement.Precision + + if len(legs) > 1 { + preview.Legs = append(preview.Legs, model.LegProjection{ + Identifier: leg.TransactionID, + Role: model.PreviewRoleSource, + PreciseAmount: preciseString(settlement.PreciseAmount), + Amount: settlement.Amount, + }) + } + } + + preview.PreciseAmount = preciseString(total) + preview.Balances = working.projections() + + span.SetAttributes( + attribute.Bool("preview.would_apply", preview.WouldApply), + attribute.Int("preview.legs", len(legs)), + ) + return preview, nil +} + +// inflightLegsForPreview resolves txID to the inflight transactions an action +// against it would settle. +// +// A transaction id resolves to itself. An id that names no inflight transaction +// directly may still be the parent of a split, so it is expanded the same way +// the real commit and void paths expand it. +func (l *Blnk) inflightLegsForPreview(ctx context.Context, txID string) ([]*model.Transaction, error) { + transaction, err := l.fetchAndValidateInflightTransaction(ctx, txID) + if err == nil { + return []*model.Transaction{transaction}, nil + } + + if !shouldExpandInflightParent(err) { + return nil, err + } + + legs, legErr := l.collectInflightLegs(ctx, txID) + if legErr != nil { + return nil, legErr + } + if len(legs) == 0 { + return nil, err + } + return legs, nil +} + +// buildInflightSettlementForPreview builds the settlement transaction one leg +// would produce, without recording it. +// +// The commit path's own validation decides the amount, so a projected commit +// refuses exactly what a real one would: an already-settled hold, or an amount +// beyond what remains. +func (l *Blnk) buildInflightSettlementForPreview(ctx context.Context, leg *model.Transaction, action string, amount *big.Int) (*model.Transaction, error) { + // Work on a copy: validateAndUpdateAmount writes the settled amount onto + // the transaction it is given. + settlement := leg.Clone() + + if action == InflightActionVoid { + amountLeft, err := l.calculateRemainingAmount(ctx, settlement) + if err != nil { + return nil, err + } + if amountLeft.Cmp(big.NewInt(0)) == 0 { + return nil, fmt.Errorf("cannot void. Transaction already committed") + } + settlement.Status = StatusVoid + settlement.PreciseAmount = amountLeft + settlement.Amount = l.convertPreciseToFloat(amountLeft, settlement.Precision) + return settlement, nil + } + + requested := amount + if requested == nil { + requested = big.NewInt(0) + } + if err := l.validateAndUpdateAmount(ctx, settlement, requested); err != nil { + return nil, err + } + settlement.Status = StatusCommit + return settlement, nil +} diff --git a/transaction_dryrun_test.go b/transaction_dryrun_test.go new file mode 100644 index 00000000..d8594384 --- /dev/null +++ b/transaction_dryrun_test.go @@ -0,0 +1,355 @@ +package blnk + +import ( + "context" + "math/big" + "regexp" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/blnkfinance/blnk/config" + "github.com/blnkfinance/blnk/model" + "github.com/go-redis/redismock/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + previewSourceID = "bln_preview_source" + previewDestinationID = "bln_preview_destination" +) + +// newPreviewTestBlnk builds a Blnk whose datasource and redis are both mocks. +// +// This is what makes the "changes no ledger state" assertions mechanical rather +// than aspirational: sqlmock fails on any query that was not explicitly +// expected, so an INSERT or UPDATE reaching the datasource fails the test by +// itself, without anyone having to assert its absence. +func newPreviewTestBlnk(t *testing.T, enableQueuedChecks bool) (*Blnk, sqlmock.Sqlmock, redismock.ClientMock) { + t.Helper() + + cnf := &config.Configuration{ + Redis: config.RedisConfig{Dns: "localhost:6379"}, + Server: config.ServerConfig{SecretKey: "some-secret"}, + TokenizationSecret: "12345678901234567890123456789012", + Queue: config.QueueConfig{ + WebhookQueue: "webhook_queue", + TransactionQueue: "transaction_queue", + IndexQueue: "index_queue", + NumberOfQueues: 1, + }, + Transaction: config.TransactionConfig{ + LockDuration: 30 * time.Second, + IndexQueuePrefix: "test_index", + EnableQueuedChecks: enableQueuedChecks, + }, + } + config.ConfigStore.Store(cnf) + + datasource, dbMock, err := newTestDataSource() + require.NoError(t, err) + dbMock.MatchExpectationsInOrder(false) + + blnk, err := NewBlnk(datasource) + require.NoError(t, err) + + redisClient, redisMock := redismock.NewClientMock() + blnk.redis = redisClient + blnk.config = cnf + + return blnk, dbMock, redisMock +} + +// expectBalanceLite queues the read getSourceAndDestination performs when +// queued checks are off. +func expectBalanceLite(dbMock sqlmock.Sqlmock, balanceID, currency string, balance, credit, debit, inflightDebit int64) { + rows := sqlmock.NewRows([]string{ + "balance_id", "indicator", "currency", "ledger_id", "balance", "credit_balance", "debit_balance", + "inflight_balance", "inflight_credit_balance", "inflight_debit_balance", "created_at", "version", + "track_fund_lineage", "allocation_strategy", "identity_id", + }).AddRow( + balanceID, nil, currency, "general_ledger_id", int64ToString(balance), int64ToString(credit), int64ToString(debit), + "0", "0", int64ToString(inflightDebit), time.Now(), 3, false, "FIFO", "", + ) + + dbMock.ExpectQuery(regexp.QuoteMeta(`SELECT balance_id, indicator, currency, ledger_id, balance, credit_balance, debit_balance, inflight_balance, inflight_credit_balance, inflight_debit_balance, created_at, version`)). + WithArgs(balanceID). + WillReturnRows(rows) +} + +func int64ToString(v int64) string { + return big.NewInt(v).String() +} + +// allowLockRoundTrip permits the redis traffic a lock acquire/release performs +// without asserting on its exact shape. +func allowLockRoundTrip(redisMock redismock.ClientMock) { + redisMock.Regexp().ExpectSetNX(`.*`, `.*`, 30*time.Second).SetVal(true) + redisMock.Regexp().ExpectSetNX(`.*`, `.*`, 30*time.Second).SetVal(true) + redisMock.Regexp().ExpectGet(`.*`).SetVal("") + redisMock.Regexp().ExpectGet(`.*`).SetVal("") +} + +func previewTransaction(amount float64) *model.Transaction { + return &model.Transaction{ + Reference: "preview_ref_1", + Source: previewSourceID, + Destination: previewDestinationID, + Amount: amount, + Precision: 100, + Currency: "USD", + } +} + +// TestPreviewTransactionProjectsWithoutWriting is the core invariant: a +// successful projection reports the resulting balances and issues no write. +// Any INSERT or UPDATE would surface as an unexpected sqlmock query. +func TestPreviewTransactionProjectsWithoutWriting(t *testing.T) { + blnk, dbMock, redisMock := newPreviewTestBlnk(t, false) + + dbMock.ExpectQuery(regexp.QuoteMeta(`SELECT EXISTS(SELECT 1 FROM blnk.transactions WHERE reference = $1)`)). + WithArgs("preview_ref_1"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + expectBalanceLite(dbMock, previewSourceID, "USD", 50000, 50000, 0, 0) + expectBalanceLite(dbMock, previewDestinationID, "USD", 0, 0, 0, 0) + allowLockRoundTrip(redisMock) + + preview, err := blnk.PreviewTransaction(context.Background(), previewTransaction(100)) + require.NoError(t, err) + + assert.True(t, preview.DryRun) + assert.True(t, preview.WouldApply) + assert.Nil(t, preview.Rejection) + assert.Equal(t, "10000", preview.PreciseAmount) + require.Len(t, preview.Balances, 2) + + source := preview.Balances[0] + assert.Equal(t, model.PreviewRoleSource, source.Role) + assert.Equal(t, "50000", source.CurrentBalance) + assert.Equal(t, "40000", source.ResultingBalance) + assert.Equal(t, "10000", source.ResultingDebitBalance) + + destination := preview.Balances[1] + assert.Equal(t, model.PreviewRoleDestination, destination.Role) + assert.Equal(t, "0", destination.CurrentBalance) + assert.Equal(t, "10000", destination.ResultingBalance) + + assert.NoError(t, dbMock.ExpectationsWereMet()) +} + +// TestPreviewTransactionDoesNotMutateCallerTransaction guards the clone: the +// apply path writes PreciseAmount onto whatever transaction it is handed. +func TestPreviewTransactionDoesNotMutateCallerTransaction(t *testing.T) { + blnk, dbMock, redisMock := newPreviewTestBlnk(t, false) + + dbMock.ExpectQuery(regexp.QuoteMeta(`SELECT EXISTS(SELECT 1 FROM blnk.transactions WHERE reference = $1)`)). + WithArgs("preview_ref_1"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + expectBalanceLite(dbMock, previewSourceID, "USD", 50000, 50000, 0, 0) + expectBalanceLite(dbMock, previewDestinationID, "USD", 0, 0, 0, 0) + allowLockRoundTrip(redisMock) + + transaction := previewTransaction(100) + _, err := blnk.PreviewTransaction(context.Background(), transaction) + require.NoError(t, err) + + assert.Nil(t, transaction.PreciseAmount, "preview must not write PreciseAmount onto the caller's transaction") + assert.Empty(t, transaction.Status, "preview must not assign a status to the caller's transaction") +} + +// TestPreviewTransactionRejectsInsufficientFunds checks the projection reports +// the rejection using the same reason a real rejection is recorded under. +func TestPreviewTransactionRejectsInsufficientFunds(t *testing.T) { + blnk, dbMock, redisMock := newPreviewTestBlnk(t, false) + + dbMock.ExpectQuery(regexp.QuoteMeta(`SELECT EXISTS(SELECT 1 FROM blnk.transactions WHERE reference = $1)`)). + WithArgs("preview_ref_1"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + expectBalanceLite(dbMock, previewSourceID, "USD", 25000, 25000, 0, 0) + expectBalanceLite(dbMock, previewDestinationID, "USD", 0, 0, 0, 0) + allowLockRoundTrip(redisMock) + + preview, err := blnk.PreviewTransaction(context.Background(), previewTransaction(999)) + require.NoError(t, err) + + assert.False(t, preview.WouldApply) + require.NotNil(t, preview.Rejection) + assert.Equal(t, "insufficient_funds", preview.Rejection.Reason) + assert.Contains(t, preview.Rejection.Message, "insufficient funds") + + // A rejected projection reports the balances unchanged: nothing would move. + assert.Equal(t, "25000", preview.Balances[0].CurrentBalance) + assert.Equal(t, "25000", preview.Balances[0].ResultingBalance) + + assert.NoError(t, dbMock.ExpectationsWereMet()) +} + +// TestPreviewTransactionZeroAmountIsRejected pins that a zero amount is a +// rejection, not a silent no-op: validate() runs inside UpdateBalances and +// errors before the zero-amount discard further down the real path is reached. +func TestPreviewTransactionZeroAmountIsRejected(t *testing.T) { + blnk, dbMock, redisMock := newPreviewTestBlnk(t, false) + + dbMock.ExpectQuery(regexp.QuoteMeta(`SELECT EXISTS(SELECT 1 FROM blnk.transactions WHERE reference = $1)`)). + WithArgs("preview_ref_1"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + expectBalanceLite(dbMock, previewSourceID, "USD", 50000, 50000, 0, 0) + expectBalanceLite(dbMock, previewDestinationID, "USD", 0, 0, 0, 0) + allowLockRoundTrip(redisMock) + + preview, err := blnk.PreviewTransaction(context.Background(), previewTransaction(0)) + require.NoError(t, err) + + assert.False(t, preview.WouldApply) + require.NotNil(t, preview.Rejection) + assert.Contains(t, preview.Rejection.Message, "must be positive") +} + +// TestPreviewTransactionAvailabilityHonoursInflight checks the available figure +// is computed the way canProcessTransaction computes it, so the preview agrees +// with what enforcement will do. +func TestPreviewTransactionAvailabilityHonoursInflight(t *testing.T) { + blnk, dbMock, redisMock := newPreviewTestBlnk(t, false) + + dbMock.ExpectQuery(regexp.QuoteMeta(`SELECT EXISTS(SELECT 1 FROM blnk.transactions WHERE reference = $1)`)). + WithArgs("preview_ref_1"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + // 500.00 on the balance, but 400.00 of it is held inflight. + expectBalanceLite(dbMock, previewSourceID, "USD", 50000, 50000, 0, 40000) + expectBalanceLite(dbMock, previewDestinationID, "USD", 0, 0, 0, 0) + allowLockRoundTrip(redisMock) + + // 200.00 is below the raw balance but above what is actually available. + preview, err := blnk.PreviewTransaction(context.Background(), previewTransaction(200)) + require.NoError(t, err) + + assert.Equal(t, "10000", preview.Balances[0].CurrentAvailable) + assert.False(t, preview.WouldApply, "a transfer above available funds must not be projected as applying") + require.NotNil(t, preview.Rejection) + assert.Equal(t, "insufficient_funds", preview.Rejection.Reason) +} + +// TestPreviewTransactionVirtualIndicatorIsNotCreated covers the promise that a +// preview never creates an @indicator balance: the lookup misses, and the +// projection runs against a zeroed stand-in instead of an INSERT. +func TestPreviewTransactionVirtualIndicatorIsNotCreated(t *testing.T) { + blnk, dbMock, redisMock := newPreviewTestBlnk(t, false) + + dbMock.ExpectQuery(regexp.QuoteMeta(`SELECT EXISTS(SELECT 1 FROM blnk.transactions WHERE reference = $1)`)). + WithArgs("preview_ref_1"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + dbMock.ExpectQuery(regexp.QuoteMeta(`SELECT balance_id, indicator, currency, ledger_id, balance, credit_balance, debit_balance, inflight_balance, inflight_credit_balance, inflight_debit_balance, created_at, version`)). + WithArgs("@NewRevenue", "USD"). + WillReturnError(sqlmock.ErrCancelled) + expectBalanceLite(dbMock, previewDestinationID, "USD", 0, 0, 0, 0) + allowLockRoundTrip(redisMock) + + transaction := previewTransaction(100) + transaction.Source = "@NewRevenue" + transaction.AllowOverdraft = true + + preview, err := blnk.PreviewTransaction(context.Background(), transaction) + require.NoError(t, err) + + require.Len(t, preview.Balances, 2) + assert.True(t, preview.Balances[0].Virtual, "an indicator with no balance yet must be reported as virtual") + assert.Equal(t, "0", preview.Balances[0].CurrentBalance) + assert.Equal(t, "-10000", preview.Balances[0].ResultingBalance) + + // No CreateBalance INSERT was expected; if one ran, this fails. + assert.NoError(t, dbMock.ExpectationsWereMet()) +} + +// TestPreviewTransactionNotesReferenceInUse checks that an already-used +// reference is surfaced as advice rather than failing the projection. +func TestPreviewTransactionNotesReferenceInUse(t *testing.T) { + blnk, dbMock, redisMock := newPreviewTestBlnk(t, false) + + dbMock.ExpectQuery(regexp.QuoteMeta(`SELECT EXISTS(SELECT 1 FROM blnk.transactions WHERE reference = $1)`)). + WithArgs("preview_ref_1"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + expectBalanceLite(dbMock, previewSourceID, "USD", 50000, 50000, 0, 0) + expectBalanceLite(dbMock, previewDestinationID, "USD", 0, 0, 0, 0) + allowLockRoundTrip(redisMock) + + preview, err := blnk.PreviewTransaction(context.Background(), previewTransaction(100)) + require.NoError(t, err) + + assert.True(t, preview.WouldApply) + require.NotEmpty(t, preview.Notes) + assert.Contains(t, preview.Notes[0], "reference is already in use") +} + +// TestPreviewTransactionNotesCurrencyMismatch pins that the preview mirrors the +// ledger rather than being stricter than it: the apply path performs no +// currency check, so a mismatch is projected with a warning, not rejected. +func TestPreviewTransactionNotesCurrencyMismatch(t *testing.T) { + blnk, dbMock, redisMock := newPreviewTestBlnk(t, false) + + dbMock.ExpectQuery(regexp.QuoteMeta(`SELECT EXISTS(SELECT 1 FROM blnk.transactions WHERE reference = $1)`)). + WithArgs("preview_ref_1"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + expectBalanceLite(dbMock, previewSourceID, "EUR", 50000, 50000, 0, 0) + expectBalanceLite(dbMock, previewDestinationID, "USD", 0, 0, 0, 0) + allowLockRoundTrip(redisMock) + + preview, err := blnk.PreviewTransaction(context.Background(), previewTransaction(100)) + require.NoError(t, err) + + assert.True(t, preview.WouldApply, "the ledger applies mismatched currencies, so the preview must too") + require.NotEmpty(t, preview.Notes) + assert.Contains(t, preview.Notes[0], "currency mismatch") +} + +// TestNormalizePreviewStatus covers the status a preview transaction carries +// into the apply path, which a real create would have picked up from the queue. +func TestNormalizePreviewStatus(t *testing.T) { + tests := []struct { + name string + txn *model.Transaction + expected string + }{ + {"unset becomes applied", &model.Transaction{}, StatusApplied}, + {"inflight becomes inflight", &model.Transaction{Inflight: true}, StatusInflight}, + {"commit is preserved", &model.Transaction{Status: StatusCommit}, StatusCommit}, + {"void is preserved", &model.Transaction{Status: StatusVoid}, StatusVoid}, + {"queued becomes applied", &model.Transaction{Status: StatusQueued}, StatusApplied}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + normalizePreviewStatus(tt.txn) + assert.Equal(t, tt.expected, tt.txn.Status) + }) + } +} + +func TestPreviewTransactionRequiresTransaction(t *testing.T) { + blnk, _, _ := newPreviewTestBlnk(t, false) + + _, err := blnk.PreviewTransaction(context.Background(), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "transaction is required") +} + +// TestAvailableBalanceMatchesEnforcement pins the availability formula against +// canProcessTransaction's, including the queued-debit term that is only present +// when queued checks are enabled. +func TestAvailableBalanceMatchesEnforcement(t *testing.T) { + t.Run("subtracts inflight debits", func(t *testing.T) { + balance := &model.Balance{Balance: big.NewInt(1000), InflightDebitBalance: big.NewInt(400)} + balance.InitializeBalanceFields() + assert.Equal(t, big.NewInt(600), availableBalance(balance)) + }) + + t.Run("subtracts queued debits when present", func(t *testing.T) { + balance := &model.Balance{ + Balance: big.NewInt(1000), + InflightDebitBalance: big.NewInt(400), + QueuedDebitBalance: big.NewInt(100), + } + balance.InitializeBalanceFields() + assert.Equal(t, big.NewInt(500), availableBalance(balance)) + }) +}