-
Notifications
You must be signed in to change notification settings - Fork 4
/
Copy pathgift_card.go
340 lines (286 loc) · 11.5 KB
/
gift_card.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
package async_account
import (
"context"
"crypto/sha256"
"errors"
"math"
"sync"
"time"
"github.com/mr-tron/base58"
"github.com/newrelic/go-agent/v3/newrelic"
"github.com/sirupsen/logrus"
commonpb "github.com/code-payments/code-protobuf-api/generated/go/common/v1"
chat_util "github.com/code-payments/code-server/pkg/code/chat"
"github.com/code-payments/code-server/pkg/code/common"
code_data "github.com/code-payments/code-server/pkg/code/data"
"github.com/code-payments/code-server/pkg/code/data/account"
"github.com/code-payments/code-server/pkg/code/data/action"
"github.com/code-payments/code-server/pkg/code/data/fulfillment"
"github.com/code-payments/code-server/pkg/code/data/intent"
"github.com/code-payments/code-server/pkg/code/push"
"github.com/code-payments/code-server/pkg/currency"
"github.com/code-payments/code-server/pkg/kin"
"github.com/code-payments/code-server/pkg/metrics"
"github.com/code-payments/code-server/pkg/pointer"
"github.com/code-payments/code-server/pkg/retry"
)
const (
giftCardAutoReturnIntentPrefix = "auto-return-gc-"
giftCardExpiry = 24 * time.Hour
)
func (p *service) giftCardAutoReturnWorker(serviceCtx context.Context, interval time.Duration) error {
delay := interval
err := retry.Loop(
func() (err error) {
time.Sleep(delay)
nr := serviceCtx.Value(metrics.NewRelicContextKey).(*newrelic.Application)
m := nr.StartTransaction("async__account_service__handle_gift_card_auto_return")
defer m.End()
tracedCtx := newrelic.NewContext(serviceCtx, m)
records, err := p.data.GetPrioritizedAccountInfosRequiringAutoReturnCheck(tracedCtx, giftCardExpiry, 10)
if err == account.ErrAccountInfoNotFound {
return nil
} else if err != nil {
m.NoticeError(err)
return err
}
var wg sync.WaitGroup
for _, record := range records {
wg.Add(1)
go func(record *account.Record) {
defer wg.Done()
err := p.maybeInitiateGiftCardAutoReturn(tracedCtx, record)
if err != nil {
m.NoticeError(err)
}
}(record)
}
wg.Wait()
return nil
},
retry.NonRetriableErrors(context.Canceled),
)
return err
}
func (p *service) maybeInitiateGiftCardAutoReturn(ctx context.Context, accountInfoRecord *account.Record) error {
log := p.log.WithFields(logrus.Fields{
"method": "maybeInitiateGiftCardAutoReturn",
"account": accountInfoRecord.TokenAccount,
})
if accountInfoRecord.AccountType != commonpb.AccountType_REMOTE_SEND_GIFT_CARD {
log.Trace("skipping account that isn't a gift card")
return errors.New("expected a gift card account")
}
giftCardVaultAccount, err := common.NewAccountFromPublicKeyString(accountInfoRecord.TokenAccount)
if err != nil {
log.WithError(err).Warn("invalid vault account")
return err
}
_, err = p.data.GetGiftCardClaimedAction(ctx, giftCardVaultAccount.PublicKey().ToBase58())
if err == nil {
log.Trace("gift card is claimed and will be removed from worker queue")
// Gift card is claimed, so take it out of the worker queue. The auto-return
// action and fulfillment will be revoked in the fulfillment worker from generic
// account closing flows.
//
// Note: It is possible the original issuer "claimed" the gift card. This is
// actually ideal because funds move in a more private manner through the
// temp incoming account versus the primary account.
return markAutoReturnCheckComplete(ctx, p.data, accountInfoRecord)
} else if err != action.ErrActionNotFound {
return err
}
// Expiration window hasn't been met
//
// Note: Without distributed locks, we assume SubmitIntent uses expiry - delta
// to ensure race conditions aren't possible
if time.Since(accountInfoRecord.CreatedAt) < giftCardExpiry {
log.Trace("skipping gift card that hasn't hit the expiry window")
return nil
}
log.Trace("initiating process to return gift card balance to issuer")
// There's no action to claim the gift card and the expiry window has been met.
// It's time to initiate the process of auto-returning the funds back to the
// issuer.
err = p.initiateProcessToAutoReturnGiftCard(ctx, giftCardVaultAccount)
if err != nil {
log.WithError(err).Warn("failure initiating process to return gift card balance to issuer")
return err
}
return markAutoReturnCheckComplete(ctx, p.data, accountInfoRecord)
}
// Note: This is the first instance of handling a conditional action, and could be
// a good guide for similar actions in the future.
func (p *service) initiateProcessToAutoReturnGiftCard(ctx context.Context, giftCardVaultAccount *common.Account) error {
giftCardIssuedIntent, err := p.data.GetOriginalGiftCardIssuedIntent(ctx, giftCardVaultAccount.PublicKey().ToBase58())
if err != nil {
return err
}
autoReturnAction, err := p.data.GetGiftCardAutoReturnAction(ctx, giftCardVaultAccount.PublicKey().ToBase58())
if err != nil {
return err
}
autoReturnFulfillment, err := p.data.GetAllFulfillmentsByAction(ctx, autoReturnAction.Intent, autoReturnAction.ActionId)
if err != nil {
return err
}
// Add a payment history item to show the funds being returned back to the issuer
err = insertAutoReturnPaymentHistoryItem(ctx, p.data, giftCardIssuedIntent)
if err != nil {
return err
}
// We need to update pre-sorting because close dormant fulfillments are always
// inserted at the very last spot in the line.
//
// Must be the first thing to succeed! We cannot risk a deposit back into the
// organizer to win a race in scheduling. By pre-sorting this to the end of
// the gift card issued intent, we ensure the auto-return is blocked on any
// fulfillments to setup the gift card. We'll also guarantee that subsequent
// intents that utilize the primary account as a source of funds will be blocked
// by the auto-return.
err = updateCloseDormantAccountFulfillmentPreSorting(
ctx,
p.data,
autoReturnFulfillment[0],
giftCardIssuedIntent.Id,
math.MaxInt32,
0,
)
if err != nil {
return err
}
// This will update the action's quantity, so balance changes are reflected. We
// also unblock fulfillment scheduling by moving the action out of the unknown
// state and into the pending state.
err = scheduleCloseDormantAccountAction(
ctx,
p.data,
autoReturnAction,
giftCardIssuedIntent.SendPrivatePaymentMetadata.Quantity,
)
if err != nil {
return err
}
// This will trigger the fulfillment worker to poll for the fulfillment. This
// should be the very last DB update called.
err = markFulfillmentAsActivelyScheduled(ctx, p.data, autoReturnFulfillment[0])
if err != nil {
return err
}
// Finally, update the user by best-effort sending them a push
go push.SendGiftCardReturnedPushNotification(
ctx,
p.data,
p.pusher,
giftCardVaultAccount,
)
return nil
}
func markAutoReturnCheckComplete(ctx context.Context, data code_data.Provider, record *account.Record) error {
if !record.RequiresAutoReturnCheck {
return nil
}
record.RequiresAutoReturnCheck = false
return data.UpdateAccountInfo(ctx, record)
}
// Note: Structured like a generic utility because it could very well evolve
// into that, but there's no reason to call this on anything else as of
// writing this comment.
func scheduleCloseDormantAccountAction(ctx context.Context, data code_data.Provider, actionRecord *action.Record, balance uint64) error {
if actionRecord.ActionType != action.CloseDormantAccount {
return errors.New("expected a close dormant account action")
}
if actionRecord.State == action.StatePending {
return nil
}
if actionRecord.State != action.StateUnknown {
return errors.New("expected action in unknown state")
}
actionRecord.State = action.StatePending
actionRecord.Quantity = pointer.Uint64(balance)
return data.UpdateAction(ctx, actionRecord)
}
// Note: Structured like a generic utility because it could very well evolve
// into that, but there's no reason to call this on anything else as of
// writing this comment.
func updateCloseDormantAccountFulfillmentPreSorting(
ctx context.Context,
data code_data.Provider,
fulfillmentRecord *fulfillment.Record,
intentOrderingIndex uint64,
actionOrderingIndex uint32,
fulfillmentOrderingIndex uint32,
) error {
if fulfillmentRecord.FulfillmentType != fulfillment.CloseDormantTimelockAccount {
return errors.New("expected a close dormant timelock account fulfillment")
}
if fulfillmentRecord.IntentOrderingIndex == intentOrderingIndex &&
fulfillmentRecord.ActionOrderingIndex == actionOrderingIndex &&
fulfillmentRecord.FulfillmentOrderingIndex == fulfillmentOrderingIndex {
return nil
}
if fulfillmentRecord.State != fulfillment.StateUnknown {
return errors.New("expected fulfillment in unknown state")
}
fulfillmentRecord.IntentOrderingIndex = intentOrderingIndex
fulfillmentRecord.ActionOrderingIndex = actionOrderingIndex
fulfillmentRecord.FulfillmentOrderingIndex = fulfillmentOrderingIndex
return data.UpdateFulfillment(ctx, fulfillmentRecord)
}
func markFulfillmentAsActivelyScheduled(ctx context.Context, data code_data.Provider, fulfillmentRecord *fulfillment.Record) error {
if fulfillmentRecord.Id == 0 {
return errors.New("fulfillment id is zero")
}
if !fulfillmentRecord.DisableActiveScheduling {
return nil
}
if fulfillmentRecord.State != fulfillment.StateUnknown {
return errors.New("expected fulfillment in unknown state")
}
// Note: different than Save, since we don't have distributed locks
return data.MarkFulfillmentAsActivelyScheduled(ctx, fulfillmentRecord.Id)
}
func insertAutoReturnPaymentHistoryItem(ctx context.Context, data code_data.Provider, giftCardIssuedIntent *intent.Record) error {
usdExchangeRecord, err := data.GetExchangeRate(ctx, currency.USD, time.Now())
if err != nil {
return err
}
// We need to insert a faked completed public receive intent so it can appear
// as a return in the user's payment history. Think of it as a server-initiated
// intent on behalf of the user based on pre-approved conditional actions.
//
// Deprecated in favour of chats (for history purposes)
//
// todo: Should we remap the CloseDormantAccount action and fulfillments, then
// tie the fulfillment/action state to the intent state? Just doing the
// easiest thing for now to get auto-return out the door.
intentRecord := &intent.Record{
IntentId: getAutoReturnIntentId(giftCardIssuedIntent.IntentId),
IntentType: intent.ReceivePaymentsPublicly,
InitiatorOwnerAccount: giftCardIssuedIntent.InitiatorOwnerAccount,
InitiatorPhoneNumber: giftCardIssuedIntent.InitiatorPhoneNumber,
ReceivePaymentsPubliclyMetadata: &intent.ReceivePaymentsPubliclyMetadata{
Source: giftCardIssuedIntent.SendPrivatePaymentMetadata.DestinationTokenAccount,
Quantity: giftCardIssuedIntent.SendPrivatePaymentMetadata.Quantity,
IsRemoteSend: true,
IsReturned: true,
OriginalExchangeCurrency: giftCardIssuedIntent.SendPrivatePaymentMetadata.ExchangeCurrency,
OriginalExchangeRate: giftCardIssuedIntent.SendPrivatePaymentMetadata.ExchangeRate,
OriginalNativeAmount: giftCardIssuedIntent.SendPrivatePaymentMetadata.NativeAmount,
UsdMarketValue: usdExchangeRecord.Rate * float64(kin.FromQuarks(giftCardIssuedIntent.SendPrivatePaymentMetadata.Quantity)),
},
State: intent.StateConfirmed,
CreatedAt: time.Now(),
}
err = data.SaveIntent(ctx, intentRecord)
if err != nil {
return err
}
return chat_util.SendCashTransactionsExchangeMessage(ctx, data, intentRecord)
}
// Must be unique, but consistent for idempotency, and ideally fit in a 32
// byte buffer.
func getAutoReturnIntentId(originalIntentId string) string {
hashed := sha256.Sum256([]byte(giftCardAutoReturnIntentPrefix + originalIntentId))
return base58.Encode(hashed[:])
}