@riocrypto/common-server 1.0.2889 → 1.0.2892

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,17 @@
1
- import { Country, Fiat, Processor, ProcessorRoutingConfig, Side } from "@riocrypto/common";
2
- export declare const getProcessor: ({ country, fiat, side, isBinanceRFQ, routingConfig, disabledProcessors, customRioBankAccount, }: {
1
+ import { Country, Fiat, Processor, ProcessorEnablementState, ProcessorRoutingConfig, ProcessorRoutingPurpose, RoutingDecision, Side } from "@riocrypto/common";
2
+ export declare const getEnvironmentExcludedProcessors: (purpose: ProcessorRoutingPurpose) => Processor[];
3
+ export type ProcessorSelection = RoutingDecision & {
4
+ processor: Processor;
5
+ };
6
+ export declare const getProcessor: ({ country, fiat, side, isBinanceRFQ, routingConfig, enablement, requireExplicitPayinEnablement, customRioBankAccount, }: {
3
7
  country: Country;
4
8
  fiat: Fiat;
5
9
  side: Side;
6
10
  isBinanceRFQ: boolean;
7
11
  routingConfig?: ProcessorRoutingConfig | undefined;
8
- disabledProcessors?: Processor[] | undefined;
12
+ enablement?: ProcessorEnablementState | undefined;
13
+ requireExplicitPayinEnablement?: boolean | undefined;
9
14
  customRioBankAccount?: {
10
15
  processor?: Processor | undefined;
11
16
  } | undefined;
12
- }) => Processor;
17
+ }) => ProcessorSelection;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getProcessor = void 0;
3
+ exports.getProcessor = exports.getEnvironmentExcludedProcessors = void 0;
4
4
  const common_1 = require("@riocrypto/common");
5
5
  const resolve_processor_1 = require("./resolve-processor");
6
6
  // Alfin takes effect only in Production (Alfin prod). In every other
@@ -18,22 +18,33 @@ const isAlfinEnabled = () => [common_1.RioEnv.Production].includes(process.env.R
18
18
  const isSTPAvailable = (purpose) => (purpose === common_1.ProcessorRoutingPurpose.Buy
19
19
  ? [common_1.RioEnv.Production]
20
20
  : [common_1.RioEnv.Production, common_1.RioEnv.Development]).includes(process.env.RIO_ENV);
21
- const getProcessor = ({ country, fiat, side, isBinanceRFQ, routingConfig, disabledProcessors, customRioBankAccount, }) => {
21
+ // Rails this deployment cannot reach for a purpose, whatever the settings say.
22
+ // Exported because seeding a customer's first payin account has to make the same
23
+ // call: handing someone an account that only exists in production would look like
24
+ // opt-in having locked them out.
25
+ const getEnvironmentExcludedProcessors = (purpose) => [
26
+ ...(isAlfinEnabled() ? [] : [common_1.Processor.Alfin]),
27
+ ...(isSTPAvailable(purpose) ? [] : [common_1.Processor.SPEI_STP]),
28
+ ];
29
+ exports.getEnvironmentExcludedProcessors = getEnvironmentExcludedProcessors;
30
+ const getProcessor = ({ country, fiat, side, isBinanceRFQ, routingConfig, enablement, requireExplicitPayinEnablement, customRioBankAccount, }) => {
22
31
  if (isBinanceRFQ === true) {
23
- return common_1.Processor.BinanceRFQ;
32
+ return {
33
+ processor: common_1.Processor.BinanceRFQ,
34
+ reason: common_1.RoutingDecisionReason.Structural,
35
+ corridor: [common_1.Processor.BinanceRFQ],
36
+ decidedAt: new Date(),
37
+ };
24
38
  }
25
39
  const purpose = side === common_1.Side.Buy
26
40
  ? common_1.ProcessorRoutingPurpose.Buy
27
41
  : common_1.ProcessorRoutingPurpose.Sell;
28
- const excludedProcessors = [
29
- ...(isAlfinEnabled() ? [] : [common_1.Processor.Alfin]),
30
- ...(isSTPAvailable(purpose) ? [] : [common_1.Processor.SPEI_STP]),
31
- ];
42
+ const excludedProcessors = (0, exports.getEnvironmentExcludedProcessors)(purpose);
32
43
  // Applied only inside the branches that genuinely have more than one rail, so
33
44
  // a stray rule cannot hijack structural routing (COP, USD in the US or Mexico)
34
45
  // or bypass the country checks below. Answers even when no rule is configured,
35
46
  // because the corridor default is only safe to use once the rails the customer
36
- // has switched off are out of the running. Undefined is left for the one case
47
+ // will not accept are out of the running. Undefined is left for the one case
37
48
  // that genuinely belongs to structural routing: a custom Rio bank account whose
38
49
  // rail we do not know.
39
50
  const trySplit = () => {
@@ -43,24 +54,30 @@ const getProcessor = ({ country, fiat, side, isBinanceRFQ, routingConfig, disabl
43
54
  purpose,
44
55
  routingConfig,
45
56
  excludedProcessors,
46
- disabledProcessors,
57
+ enablement,
58
+ requireExplicitEnablement: requireExplicitPayinEnablement,
47
59
  customRioBankAccount,
48
60
  });
49
61
  if (routed.processor) {
50
- return routed.processor;
62
+ return Object.assign(Object.assign({}, routed), { processor: routed.processor });
63
+ }
64
+ // The customer has never told us which of these accounts their bank will let
65
+ // them pay, so there is nothing to route to. Says so in their terms, since
66
+ // the fix is theirs to make rather than something support has to escalate.
67
+ if (routed.reason === common_1.RoutingDecisionReason.NoAcceptedCandidate) {
68
+ throw new common_1.GenericInputError("You need to turn on a payment account for this currency before you can buy. You can do that from the payment accounts page, once your bank allows transfers to it.");
51
69
  }
52
70
  // Every rail in this corridor is switched off for this direction, so there is
53
71
  // nowhere to send them - a rail at zero percent would still have taken the
54
- // order. Says so specifically, and names where to fix it, since the fix is
55
- // theirs to make rather than something support has to escalate.
56
- if (routed.reason === "noEnabledCandidate") {
72
+ // order. Says so specifically, and names where to fix it.
73
+ if (routed.reason === common_1.RoutingDecisionReason.NoEnabledCandidate) {
57
74
  throw new common_1.GenericInputError(side === common_1.Side.Buy
58
75
  ? "You need to enable a payment account for this currency before you can buy. You can turn one back on from the payment accounts page."
59
76
  : "You need to enable a payout account for this currency before you can sell. You can turn one back on from the payout accounts page.");
60
77
  }
61
78
  // No rail in the corridor can be reached in this environment at all, which is
62
79
  // a deployment fact rather than anything the customer can act on.
63
- if (routed.reason === "noEligibleCandidate") {
80
+ if (routed.reason === common_1.RoutingDecisionReason.NoEligibleCandidate) {
64
81
  throw new common_1.GenericInputError("No payment processor is currently available for this order");
65
82
  }
66
83
  return undefined;
@@ -75,7 +92,13 @@ const getProcessor = ({ country, fiat, side, isBinanceRFQ, routingConfig, disabl
75
92
  if (!fallback) {
76
93
  throw new common_1.GenericInputError("No payment processor is configured for this currency");
77
94
  }
78
- return fallback;
95
+ return {
96
+ processor: fallback,
97
+ reason: common_1.RoutingDecisionReason.Structural,
98
+ corridor: (0, common_1.getProcessorsForCorridor)(country, fiat, purpose),
99
+ excluded: excludedProcessors,
100
+ decidedAt: new Date(),
101
+ };
79
102
  };
80
103
  if (fiat === common_1.Fiat.COP) {
81
104
  if (country === common_1.Country.Colombia) {
@@ -50,11 +50,12 @@ export interface PayinDestination {
50
50
  isEnabled: boolean;
51
51
  readinessStatus?: ProcessorReadinessStatus;
52
52
  }
53
- export declare const getPayinDestinations: ({ mongoose, user, country, fiat, processors, provision, }: {
53
+ export declare const getPayinDestinations: ({ mongoose, user, country, fiat, processors, provision, requireExplicitEnablement, }: {
54
54
  mongoose: Mongoose;
55
55
  user: User;
56
56
  country: Country;
57
57
  fiat: Fiat;
58
58
  processors: Processor[];
59
59
  provision?: boolean | undefined;
60
+ requireExplicitEnablement?: boolean | undefined;
60
61
  }) => Promise<PayinDestination[]>;
@@ -17,6 +17,7 @@ const fintoc_deposit_CLABE_1 = require("../models/fintoc-deposit-CLABE");
17
17
  const alfin_virtual_cci_1 = require("../models/alfin-virtual-cci");
18
18
  const processor_readiness_1 = require("../models/processor-readiness");
19
19
  const cluster_client_1 = require("../clients/cluster-client");
20
+ const processor_enablement_1 = require("./processor-enablement");
20
21
  // Rails that give each user their own destination, which therefore has to exist
21
22
  // before we can tell a customer where to pay. Every other rail is a shared house
22
23
  // account that is always available.
@@ -288,7 +289,7 @@ exports.getPayinBankDetails = getPayinBankDetails;
288
289
  // that tells them what to whitelist at their bank. Read-only by default so that
289
290
  // viewing the list never allocates anything at a provider; pass `provision` to
290
291
  // create the destinations that do not exist yet.
291
- const getPayinDestinations = ({ mongoose, user, country, fiat, processors, provision = false, }) => __awaiter(void 0, void 0, void 0, function* () {
292
+ const getPayinDestinations = ({ mongoose, user, country, fiat, processors, provision = false, requireExplicitEnablement, }) => __awaiter(void 0, void 0, void 0, function* () {
292
293
  var _d, _e;
293
294
  const ProcessorReadiness = (0, processor_readiness_1.buildProcessorReadiness)(mongoose);
294
295
  const readiness = yield ProcessorReadiness.find({
@@ -300,9 +301,12 @@ const getPayinDestinations = ({ mongoose, user, country, fiat, processors, provi
300
301
  .lean();
301
302
  const readinessByProcessor = new Map(readiness.map((record) => [record.processor, record]));
302
303
  const isEnabled = (processor) => {
303
- var _a, _b, _c;
304
- return ((_c = (_b = (_a = readinessByProcessor.get(processor)) === null || _a === void 0 ? void 0 : _a.enablement) === null || _b === void 0 ? void 0 : _b[common_1.Side.Buy]) === null || _c === void 0 ? void 0 : _c.isEnabled) !==
305
- false;
304
+ var _a;
305
+ return (0, processor_enablement_1.isProcessorEnabled)({
306
+ enablement: (_a = readinessByProcessor.get(processor)) === null || _a === void 0 ? void 0 : _a.enablement,
307
+ side: common_1.Side.Buy,
308
+ requireExplicitEnablement,
309
+ });
306
310
  };
307
311
  const destinations = [];
308
312
  for (const processor of processors) {
@@ -53,10 +53,11 @@ exports.extractTestReference = extractTestReference;
53
53
  // arrives. Deliberately does not touch an already-verified record - there is
54
54
  // nothing left to prove.
55
55
  //
56
- // Proving a rail works does not switch it on, and never switching it on does not
57
- // stop it being used: enablement is the customer's own decision and lives on the
58
- // same record under enablement. A test is how they find out a rail works before an
59
- // order depends on it, not how they get access to it.
56
+ // Passing the test does switch the rail on for buys, when the customer has not
57
+ // already said either way. Sending us the deposit is their bank demonstrating the
58
+ // transfer is allowed, which is the same thing the toggle asserts, so asking for
59
+ // both would be asking them to confirm what we just watched happen. It never
60
+ // overrides a rail they have deliberately switched off.
60
61
  //
61
62
  // The token does not expire. Whitelisting an account at a bank can take days of
62
63
  // back and forth at the customer's end, and expiring the token in the middle of
@@ -144,6 +145,20 @@ const matchPendingPayinVerification = ({ mongoose, reference, amount, fiat, proc
144
145
  if (!matched) {
145
146
  return undefined;
146
147
  }
148
+ // Left out of the transition above so that a failure here cannot cost us the
149
+ // verification itself, which is the part that must not be repeated. A rail
150
+ // switched off keeps its answer, and one already on is not restamped.
151
+ yield ProcessorReadiness.updateOne({
152
+ _id: matched._id,
153
+ [`enablement.${common_1.Side.Buy}`]: { $exists: false },
154
+ }, {
155
+ $set: {
156
+ [`enablement.${common_1.Side.Buy}`]: {
157
+ isEnabled: true,
158
+ updatedAt: new Date(),
159
+ },
160
+ },
161
+ });
147
162
  return {
148
163
  userId: matched.userId,
149
164
  country: matched.country,
@@ -1,13 +1,25 @@
1
- import { Country, Fiat, Processor, Side } from "@riocrypto/common";
1
+ import { Country, Fiat, Processor, ProcessorEnablementState, Side } from "@riocrypto/common";
2
2
  import { Mongoose } from "mongoose";
3
- export declare const getDisabledProcessors: ({ mongoose, userId, country, fiat, side, }: {
3
+ export declare const isProcessorEnabled: ({ enablement, side, requireExplicitEnablement, }: {
4
+ enablement?: {
5
+ buy?: {
6
+ isEnabled: boolean;
7
+ } | undefined;
8
+ sell?: {
9
+ isEnabled: boolean;
10
+ } | undefined;
11
+ } | undefined;
12
+ side: Side;
13
+ requireExplicitEnablement?: boolean | undefined;
14
+ }) => boolean;
15
+ export declare const getProcessorEnablementState: ({ mongoose, userId, country, fiat, side, }: {
4
16
  mongoose: Mongoose;
5
17
  userId: string;
6
18
  country: Country;
7
19
  fiat: Fiat;
8
20
  side: Side;
9
- }) => Promise<Processor[]>;
10
- export declare const setProcessorEnablement: ({ mongoose, userId, country, fiat, side, processor, isEnabled, adminId, }: {
21
+ }) => Promise<ProcessorEnablementState>;
22
+ export declare const setProcessorEnablement: ({ mongoose, userId, country, fiat, side, processor, isEnabled, adminId, requireExplicitEnablement, }: {
11
23
  mongoose: Mongoose;
12
24
  userId: string;
13
25
  country: Country;
@@ -16,4 +28,11 @@ export declare const setProcessorEnablement: ({ mongoose, userId, country, fiat,
16
28
  processor: Processor;
17
29
  isEnabled: boolean;
18
30
  adminId?: string | undefined;
31
+ requireExplicitEnablement?: boolean | undefined;
19
32
  }) => Promise<void>;
33
+ export declare const ensureDefaultPayinEnablement: ({ mongoose, userId, country, fiat, }: {
34
+ mongoose: Mongoose;
35
+ userId: string;
36
+ country: Country;
37
+ fiat: Fiat;
38
+ }) => Promise<Processor | undefined>;
@@ -9,34 +9,69 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  });
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.setProcessorEnablement = exports.getDisabledProcessors = void 0;
12
+ exports.ensureDefaultPayinEnablement = exports.setProcessorEnablement = exports.getProcessorEnablementState = exports.isProcessorEnabled = void 0;
13
13
  const common_1 = require("@riocrypto/common");
14
14
  const processor_readiness_1 = require("../models/processor-readiness");
15
+ const get_processor_1 = require("./get-processor");
15
16
  // Enablement is asked per side, so the purpose is fixed by the direction rather
16
17
  // than chosen by the caller: verifications are ours to place and are never gated.
17
18
  const getPurpose = (side) => side === common_1.Side.Buy
18
19
  ? common_1.ProcessorRoutingPurpose.Buy
19
20
  : common_1.ProcessorRoutingPurpose.Sell;
20
- // Rails the user has switched off for one corridor and one direction, which order
21
- // routing skips. On a buy that means a rail their bank will not let them send
22
- // money to; on a sell, one it will not accept credits from.
21
+ // Whether one rail may carry one customer's orders in one direction.
23
22
  //
24
- // Only explicit refusals count. A rail with no record is enabled, which is what
25
- // keeps a customer who has never opened their account settings trading exactly as
26
- // they did before.
27
- const getDisabledProcessors = ({ mongoose, userId, country, fiat, side, }) => __awaiter(void 0, void 0, void 0, function* () {
23
+ // The two directions disagree about silence, which is the whole point. A payout
24
+ // needs nothing arranged at the customer's bank, so a rail nobody has spoken
25
+ // about can pay them. A payin asks them to send money to an account their bank
26
+ // has to allow first, so silence means no while payin accounts are opt-in.
27
+ const isProcessorEnabled = ({ enablement, side, requireExplicitEnablement, }) => {
28
+ var _a, _b;
29
+ return (_b = (_a = enablement === null || enablement === void 0 ? void 0 : enablement[side]) === null || _a === void 0 ? void 0 : _a.isEnabled) !== null && _b !== void 0 ? _b : (side === common_1.Side.Sell || !requireExplicitEnablement);
30
+ };
31
+ exports.isProcessorEnabled = isProcessorEnabled;
32
+ // What a customer has said about each rail in one corridor and one direction.
33
+ //
34
+ // Reports the three sets rather than a verdict so that the policy lives in one
35
+ // place, in routing, instead of being decided here and again wherever a page
36
+ // needs to show the same thing. Only rails the corridor currently serves are
37
+ // reported, so a record left behind by a rail we no longer route to is ignored
38
+ // rather than resurrected.
39
+ const getProcessorEnablementState = ({ mongoose, userId, country, fiat, side, }) => __awaiter(void 0, void 0, void 0, function* () {
28
40
  const ProcessorReadiness = (0, processor_readiness_1.buildProcessorReadiness)(mongoose);
29
- const readiness = yield ProcessorReadiness.find({
41
+ const records = yield ProcessorReadiness.find({
30
42
  userId,
31
43
  country,
32
44
  fiat,
33
- [`enablement.${side}.isEnabled`]: false,
34
45
  })
35
- .select({ processor: 1 })
46
+ .select({ processor: 1, enablement: 1 })
36
47
  .lean();
37
- return readiness.map((record) => record.processor);
48
+ const stated = new Map(records.map((record) => {
49
+ var _a, _b;
50
+ return [
51
+ record.processor,
52
+ (_b = (_a = record.enablement) === null || _a === void 0 ? void 0 : _a[side]) === null || _b === void 0 ? void 0 : _b.isEnabled,
53
+ ];
54
+ }));
55
+ const state = {
56
+ enabled: [],
57
+ disabled: [],
58
+ unset: [],
59
+ };
60
+ for (const processor of (0, common_1.getProcessorsForCorridor)(country, fiat, getPurpose(side))) {
61
+ const isEnabled = stated.get(processor);
62
+ if (isEnabled === true) {
63
+ state.enabled.push(processor);
64
+ }
65
+ else if (isEnabled === false) {
66
+ state.disabled.push(processor);
67
+ }
68
+ else {
69
+ state.unset.push(processor);
70
+ }
71
+ }
72
+ return state;
38
73
  });
39
- exports.getDisabledProcessors = getDisabledProcessors;
74
+ exports.getProcessorEnablementState = getProcessorEnablementState;
40
75
  // Turns a rail on or off for one user in one direction, which is what order
41
76
  // routing gates on. Deliberately says nothing about verification: a customer can
42
77
  // enable a rail they have never tested, and switching one off leaves its test
@@ -44,8 +79,9 @@ exports.getDisabledProcessors = getDisabledProcessors;
44
79
  //
45
80
  // Refuses to switch off the last rail standing. Enabling nothing means we have
46
81
  // nowhere to send their money, and finding that out when they try to place an
47
- // order is worse than being told here.
48
- const setProcessorEnablement = ({ mongoose, userId, country, fiat, side, processor, isEnabled, adminId, }) => __awaiter(void 0, void 0, void 0, function* () {
82
+ // order is worse than being told here. Turning another on first is always open to
83
+ // them, so nobody is stuck with a rail they cannot use.
84
+ const setProcessorEnablement = ({ mongoose, userId, country, fiat, side, processor, isEnabled, adminId, requireExplicitEnablement, }) => __awaiter(void 0, void 0, void 0, function* () {
49
85
  const corridorProcessors = (0, common_1.getProcessorsForCorridor)(country, fiat, getPurpose(side));
50
86
  if (!corridorProcessors.includes(processor)) {
51
87
  throw new common_1.GenericInputError(side === common_1.Side.Buy
@@ -53,15 +89,18 @@ const setProcessorEnablement = ({ mongoose, userId, country, fiat, side, process
53
89
  : "This payout rail is not available for this country and currency");
54
90
  }
55
91
  if (!isEnabled) {
56
- const disabled = yield (0, exports.getDisabledProcessors)({
92
+ const state = yield (0, exports.getProcessorEnablementState)({
57
93
  mongoose,
58
94
  userId,
59
95
  country,
60
96
  fiat,
61
97
  side,
62
98
  });
63
- const stillEnabled = corridorProcessors.filter((candidate) => candidate !== processor && !disabled.includes(candidate));
64
- if (!stillEnabled.length) {
99
+ const stillUsable = [
100
+ ...state.enabled,
101
+ ...(side === common_1.Side.Sell || !requireExplicitEnablement ? state.unset : []),
102
+ ].filter((candidate) => candidate !== processor);
103
+ if (!stillUsable.length) {
65
104
  throw new common_1.GenericInputError(side === common_1.Side.Buy
66
105
  ? "At least one payment account has to stay enabled for this currency, otherwise there is nowhere to send your orders"
67
106
  : "At least one payout account has to stay enabled for this currency, otherwise there is nowhere to pay your orders from");
@@ -85,3 +124,49 @@ const setProcessorEnablement = ({ mongoose, userId, country, fiat, side, process
85
124
  }, { upsert: true });
86
125
  });
87
126
  exports.setProcessorEnablement = setProcessorEnablement;
127
+ // Gives a customer with no payin account turned on the one their corridor would
128
+ // have routed them to anyway, so opt-in never leaves somebody unable to be
129
+ // quoted. Called when an onboarding is approved, and by the backfill for accounts
130
+ // that predate all of this.
131
+ //
132
+ // Skips anyone who has already turned a rail on, because their answer is better
133
+ // than our guess and adding to it would put their money somewhere they have not
134
+ // agreed to. Where the default rail is one they have switched off, it falls to
135
+ // the corridor's own order, which is the rail routing would have picked for them
136
+ // before opt-in existed. Returns the rail it turned on, or nothing if it left
137
+ // them alone.
138
+ const ensureDefaultPayinEnablement = ({ mongoose, userId, country, fiat, }) => __awaiter(void 0, void 0, void 0, function* () {
139
+ const state = yield (0, exports.getProcessorEnablementState)({
140
+ mongoose,
141
+ userId,
142
+ country,
143
+ fiat,
144
+ side: common_1.Side.Buy,
145
+ });
146
+ if (state.enabled.length) {
147
+ return undefined;
148
+ }
149
+ // Environment exclusions are honoured here rather than left to routing, since
150
+ // seeding a rail this deployment cannot reach would hand the customer an
151
+ // account nothing can route to and read as opt-in having blocked them.
152
+ const unreachable = [
153
+ ...(0, get_processor_1.getEnvironmentExcludedProcessors)(common_1.ProcessorRoutingPurpose.Buy),
154
+ ...state.disabled,
155
+ ];
156
+ const seed = (0, common_1.getDefaultProcessor)(country, fiat, common_1.ProcessorRoutingPurpose.Buy, unreachable) ||
157
+ state.unset.find((candidate) => !unreachable.includes(candidate));
158
+ if (!seed || !state.unset.includes(seed)) {
159
+ return undefined;
160
+ }
161
+ yield (0, exports.setProcessorEnablement)({
162
+ mongoose,
163
+ userId,
164
+ country,
165
+ fiat,
166
+ side: common_1.Side.Buy,
167
+ processor: seed,
168
+ isEnabled: true,
169
+ });
170
+ return seed;
171
+ });
172
+ exports.ensureDefaultPayinEnablement = ensureDefaultPayinEnablement;
@@ -22,16 +22,38 @@ const DUPLICATE_KEY_ERROR_CODE = 11000;
22
22
  // penny test, because it is a real payment at real size. Called from the payment
23
23
  // matching paths so the record stays current without anyone maintaining it.
24
24
  //
25
- // Only touches verification. A rail the customer has switched off stays off even
26
- // if a payment turns up on it, because the account they cannot send to next month
27
- // is not made usable by one that arrived this month.
25
+ // Also turns the rail on for buys when the customer has never said either way,
26
+ // which is what keeps opt-in from needing a second migration. A payin arriving is
27
+ // their bank demonstrating the transfer is allowed, so asking them to also flip a
28
+ // switch would be asking them to confirm something we just watched happen.
28
29
  //
29
- // Never downgrades an existing verified record: an admin override stays
30
- // attributed to the admin who granted it.
30
+ // Never overrides an explicit answer in either direction. A rail the customer
31
+ // switched off stays off even if a payment turns up on it, because the account
32
+ // they cannot send to next month is not made usable by one that arrived this
33
+ // month, and a verified record keeps the admin attribution it was granted with.
31
34
  const recordObservedProcessorReadiness = ({ mongoose, userId, country, fiat, processor, CLABE, }) => __awaiter(void 0, void 0, void 0, function* () {
32
35
  var _a;
33
36
  const ProcessorReadiness = (0, processor_readiness_1.buildProcessorReadiness)(mongoose);
34
37
  try {
38
+ const enablement = {
39
+ isEnabled: true,
40
+ updatedAt: new Date(),
41
+ };
42
+ // Separate from the write below because the two have different conditions.
43
+ // Verification is skipped once verified, while enablement is skipped once the
44
+ // customer has answered, and folding them together would let one already
45
+ // satisfied condition suppress the other.
46
+ yield ProcessorReadiness.updateOne({
47
+ userId,
48
+ country,
49
+ fiat,
50
+ processor,
51
+ [`enablement.${common_1.Side.Buy}`]: { $exists: false },
52
+ }, {
53
+ $set: {
54
+ [`enablement.${common_1.Side.Buy}`]: enablement,
55
+ },
56
+ });
35
57
  yield ProcessorReadiness.updateOne({
36
58
  userId,
37
59
  country,
@@ -46,6 +68,9 @@ const recordObservedProcessorReadiness = ({ mongoose, userId, country, fiat, pro
46
68
  country,
47
69
  fiat,
48
70
  processor,
71
+ // The write above cannot reach a record that does not exist yet, and
72
+ // this one creates it.
73
+ [`enablement.${common_1.Side.Buy}`]: enablement,
49
74
  },
50
75
  }, { upsert: true });
51
76
  }
@@ -1,18 +1,16 @@
1
- import { Country, Fiat, Processor, ProcessorRoutingConfig, ProcessorRoutingPurpose } from "@riocrypto/common";
1
+ import { Country, Fiat, Processor, ProcessorEnablementState, ProcessorRoutingConfig, ProcessorRoutingPurpose, RoutingDecision } from "@riocrypto/common";
2
2
  export interface ResolveProcessorParams {
3
3
  country: Country;
4
4
  fiat: Fiat;
5
5
  purpose: ProcessorRoutingPurpose;
6
6
  routingConfig?: ProcessorRoutingConfig;
7
7
  excludedProcessors?: Processor[];
8
- disabledProcessors?: Processor[];
8
+ enablement?: ProcessorEnablementState;
9
+ requireExplicitEnablement?: boolean;
9
10
  customRioBankAccount?: {
10
11
  processor?: Processor;
11
12
  };
12
13
  randomSeed?: number;
13
14
  }
14
- export interface ResolveProcessorResult {
15
- processor?: Processor;
16
- reason: "customRioBankAccount" | "customRioBankAccountWithoutRail" | "split" | "onlyCandidate" | "enabledRailWithoutShare" | "noEligibleCandidate" | "noEnabledCandidate";
17
- }
18
- export declare const resolveProcessor: ({ country, fiat, purpose, routingConfig, excludedProcessors, disabledProcessors, customRioBankAccount, randomSeed, }: ResolveProcessorParams) => ResolveProcessorResult;
15
+ export type ResolveProcessorResult = RoutingDecision;
16
+ export declare const resolveProcessor: ({ country, fiat, purpose, routingConfig, excludedProcessors, enablement, requireExplicitEnablement, customRioBankAccount, randomSeed, }: ResolveProcessorParams) => ResolveProcessorResult;
@@ -25,30 +25,28 @@ const pickByShare = (candidates, position) => {
25
25
  // A percentage divides volume between the rails that are actually available to
26
26
  // the customer; it is not a switch. A rail left at zero, left out of the rule, or
27
27
  // belonging to a corridor with no rule at all still takes their orders when it is
28
- // the only one they have left enabled, which is why the candidate set comes from
29
- // the corridor rather than from the rule. Switching a rail off is the only way to
30
- // keep an order away from it.
28
+ // the only one available to them, which is why the candidate set comes from the
29
+ // corridor rather than from the rule. What keeps an order away from a rail is the
30
+ // customer, by switching it off or, on buys, by never turning it on.
31
31
  //
32
- // Does no IO of its own - the routing config and the user's readiness are passed
32
+ // Does no IO of its own - the routing config and the user's enablement are passed
33
33
  // in - and the only nondeterminism is the per-order roll, which a caller can
34
34
  // pin down by supplying randomSeed. Because the roll happens once, the answer
35
35
  // belongs to the record that stores it: anything that needs to know which rail
36
36
  // an existing order uses reads it off the order rather than calling again.
37
37
  //
38
+ // Returns the sets it filtered as well as the rail, because they are gone by the
39
+ // time anyone asks why. Settings move, a customer turns a rail off, an
40
+ // environment changes, and the question "why did this order go there" is then
41
+ // unanswerable from the order alone.
42
+ //
38
43
  // Only handles rails that participate in a split. Structural routing that is not
39
44
  // a commercial choice - Binance RFQ, COP, USD in the US or Mexico - stays in
40
45
  // getProcessor and never reaches here.
41
- const resolveProcessor = ({ country, fiat, purpose, routingConfig, excludedProcessors, disabledProcessors, customRioBankAccount, randomSeed, }) => {
46
+ const resolveProcessor = ({ country, fiat, purpose, routingConfig, excludedProcessors, enablement, requireExplicitEnablement, customRioBankAccount, randomSeed, }) => {
47
+ const corridor = (0, common_1.getProcessorsForCorridor)(country, fiat, purpose);
48
+ const decidedAt = new Date();
42
49
  const isEligible = (processor) => !(excludedProcessors === null || excludedProcessors === void 0 ? void 0 : excludedProcessors.includes(processor));
43
- // A rail the customer has switched off for this direction is worthless whatever
44
- // share it carries: on a buy they cannot send money to it, on a sell their bank
45
- // will not accept the credit. Applies to every rail, verifiable or not - a bank
46
- // can refuse a wire from Bancrea as easily as a SPEI transfer from Fintoc.
47
- //
48
- // Verifications are exempt because they are ours to place against an account the
49
- // customer gave us, not a rail they have to accept.
50
- const isUsable = (processor) => purpose === common_1.ProcessorRoutingPurpose.BankAccountVerification ||
51
- !(disabledProcessors === null || disabledProcessors === void 0 ? void 0 : disabledProcessors.includes(processor));
52
50
  // A custom Rio bank account outranks everything on buys, because it is where
53
51
  // the money physically goes: `getBankDetails` returns it in place of the
54
52
  // rail's own destination. Routing the order to a different processor would
@@ -62,16 +60,30 @@ const resolveProcessor = ({ country, fiat, purpose, routingConfig, excludedProce
62
60
  // to the structural path and leave these users exactly as they were until an
63
61
  // admin records which rail the account belongs to.
64
62
  if (!processor) {
65
- return { reason: "customRioBankAccountWithoutRail" };
63
+ return {
64
+ reason: common_1.RoutingDecisionReason.CustomRioBankAccountWithoutRail,
65
+ corridor,
66
+ decidedAt,
67
+ };
66
68
  }
67
69
  // Deliberately fail closed rather than fall through to the split. The payin
68
70
  // destination is fixed, so if that rail cannot be used here the only honest
69
71
  // options are to refuse the order or to keep sending payins to a provider we
70
72
  // have stopped watching.
71
73
  if (!isEligible(processor)) {
72
- return { reason: "noEligibleCandidate" };
74
+ return {
75
+ reason: common_1.RoutingDecisionReason.NoEligibleCandidate,
76
+ corridor,
77
+ excluded: excludedProcessors,
78
+ decidedAt,
79
+ };
73
80
  }
74
- return { processor, reason: "customRioBankAccount" };
81
+ return {
82
+ processor,
83
+ reason: common_1.RoutingDecisionReason.CustomRioBankAccount,
84
+ corridor,
85
+ decidedAt,
86
+ };
75
87
  }
76
88
  // An absent rule is read as every rail at zero rather than as a reason to stop.
77
89
  // Bailing out here would hand the decision to the structural default, which
@@ -83,14 +95,43 @@ const resolveProcessor = ({ country, fiat, purpose, routingConfig, excludedProce
83
95
  // because a percentage decides how volume is divided between the rails a
84
96
  // customer can use - it is not what makes a rail usable. Also drops any share
85
97
  // stored against a rail this purpose does not serve, which nothing dispatches.
86
- const corridorProcessors = (0, common_1.getProcessorsForCorridor)(country, fiat, purpose);
87
- const eligible = corridorProcessors.filter(isEligible);
98
+ const eligible = corridor.filter(isEligible);
88
99
  if (!eligible.length) {
89
- return { reason: "noEligibleCandidate" };
100
+ return {
101
+ reason: common_1.RoutingDecisionReason.NoEligibleCandidate,
102
+ corridor,
103
+ excluded: excludedProcessors,
104
+ decidedAt,
105
+ };
90
106
  }
91
- const candidates = eligible.filter(isUsable);
107
+ // A rail the customer has switched off for this direction is worthless whatever
108
+ // share it carries: on a buy they cannot send money to it, on a sell their bank
109
+ // will not accept the credit. Applies to every rail, verifiable or not - a bank
110
+ // can refuse a wire from Bancrea as easily as a SPEI transfer from Fintoc.
111
+ //
112
+ // Verifications are exempt because they are ours to place against an account the
113
+ // customer gave us, not a rail they have to accept.
114
+ const isGated = purpose !== common_1.ProcessorRoutingPurpose.BankAccountVerification;
115
+ const disabled = isGated ? (enablement === null || enablement === void 0 ? void 0 : enablement.disabled) || [] : [];
116
+ const unset = isGated && requireExplicitEnablement && purpose === common_1.ProcessorRoutingPurpose.Buy
117
+ ? (enablement === null || enablement === void 0 ? void 0 : enablement.unset) || []
118
+ : [];
119
+ const candidates = eligible.filter((processor) => !disabled.includes(processor) && !unset.includes(processor));
92
120
  if (!candidates.length) {
93
- return { reason: "noEnabledCandidate" };
121
+ return {
122
+ // Told apart because they are different conversations. One customer turned
123
+ // their rails off and can turn one back on; the other has never told us
124
+ // which account their bank will let them pay, and needs to whitelist one
125
+ // before anything can be routed.
126
+ reason: unset.length
127
+ ? common_1.RoutingDecisionReason.NoAcceptedCandidate
128
+ : common_1.RoutingDecisionReason.NoEnabledCandidate,
129
+ corridor,
130
+ excluded: excludedProcessors,
131
+ disabled,
132
+ unset,
133
+ decidedAt,
134
+ };
94
135
  }
95
136
  const shares = candidates
96
137
  .map((processor) => {
@@ -101,6 +142,7 @@ const resolveProcessor = ({ country, fiat, purpose, routingConfig, excludedProce
101
142
  });
102
143
  })
103
144
  .filter((candidate) => candidate.percentage > 0);
145
+ const percentages = Object.fromEntries(shares.map((share) => [share.processor, share.percentage]));
104
146
  // None of the rails this customer can use carries a share - or no rule is
105
147
  // configured at all - so there is no split to draw from. Refusing the order
106
148
  // would read a zero as "never use this rail", when all it says is where volume
@@ -111,18 +153,40 @@ const resolveProcessor = ({ country, fiat, purpose, routingConfig, excludedProce
111
153
  const fallback = (0, common_1.getDefaultProcessor)(country, fiat, purpose, excludedProcessors);
112
154
  return {
113
155
  processor: fallback && candidates.includes(fallback) ? fallback : candidates[0],
114
- reason: "enabledRailWithoutShare",
156
+ reason: common_1.RoutingDecisionReason.EnabledRailWithoutShare,
157
+ corridor,
158
+ excluded: excludedProcessors,
159
+ disabled,
160
+ unset,
161
+ decidedAt,
115
162
  };
116
163
  }
117
164
  if (shares.length === 1) {
118
- return { processor: shares[0].processor, reason: "onlyCandidate" };
165
+ return {
166
+ processor: shares[0].processor,
167
+ reason: common_1.RoutingDecisionReason.OnlyCandidate,
168
+ corridor,
169
+ excluded: excludedProcessors,
170
+ disabled,
171
+ unset,
172
+ percentages,
173
+ decidedAt,
174
+ };
119
175
  }
120
176
  // Rolled per order, so the realized share converges on the configured
121
177
  // percentages by order count. Nothing is persisted here: a caller that needs
122
178
  // the same answer twice passes back the seed it used.
179
+ const seed = randomSeed !== null && randomSeed !== void 0 ? randomSeed : Math.random();
123
180
  return {
124
- processor: pickByShare(shares, randomSeed !== null && randomSeed !== void 0 ? randomSeed : Math.random()),
125
- reason: "split",
181
+ processor: pickByShare(shares, seed),
182
+ reason: common_1.RoutingDecisionReason.Split,
183
+ corridor,
184
+ excluded: excludedProcessors,
185
+ disabled,
186
+ unset,
187
+ percentages,
188
+ seed,
189
+ decidedAt,
126
190
  };
127
191
  };
128
192
  exports.resolveProcessor = resolveProcessor;
@@ -1,4 +1,4 @@
1
- import { Country, Fees, Fiat, LiquidityProvider, OrderStatus, PaymentMethod, PayoutMethod, Processor, Side, Crypto, OrderAttribute, DeferredPaymentType, TwoWaySettlementType, OrderType } from "@riocrypto/common";
1
+ import { Country, Fees, Fiat, LiquidityProvider, OrderStatus, PaymentMethod, PayoutMethod, Processor, RoutingDecision, Side, Crypto, OrderAttribute, DeferredPaymentType, TwoWaySettlementType, OrderType } from "@riocrypto/common";
2
2
  import { Mongoose, Model, Document, HydratedDocument } from "mongoose";
3
3
  interface OrderAttrs {
4
4
  quoteId: string;
@@ -21,6 +21,7 @@ interface OrderAttrs {
21
21
  amountCryptoReceived?: number;
22
22
  liquidityProvider: LiquidityProvider;
23
23
  processor: Processor;
24
+ routingDecision?: RoutingDecision;
24
25
  fees: Fees;
25
26
  actualFees?: Fees;
26
27
  wireMessage?: string;
@@ -172,6 +173,7 @@ interface OrderDoc extends Document {
172
173
  amountCryptoReceived?: number;
173
174
  liquidityProvider: LiquidityProvider;
174
175
  processor: Processor;
176
+ routingDecision?: RoutingDecision;
175
177
  fees: Fees;
176
178
  actualFees?: Fees;
177
179
  marketPrice: number;
@@ -110,6 +110,9 @@ const buildOrder = (mongoose) => {
110
110
  type: String,
111
111
  required: true,
112
112
  },
113
+ routingDecision: {
114
+ type: Object,
115
+ },
113
116
  liquidityProvider: {
114
117
  type: String,
115
118
  required: true,
@@ -1,4 +1,4 @@
1
- import { Country, Fees, Fiat, LiquidityProvider, PaymentMethod, PayoutMethod, Processor, Side, Crypto, DeferredPaymentType, TwoWaySettlementType, OrderType } from "@riocrypto/common";
1
+ import { Country, Fees, Fiat, LiquidityProvider, PaymentMethod, PayoutMethod, Processor, RoutingDecision, Side, Crypto, DeferredPaymentType, TwoWaySettlementType, OrderType } from "@riocrypto/common";
2
2
  import { Mongoose, Model, Document, HydratedDocument } from "mongoose";
3
3
  interface QuoteAttrs {
4
4
  userId: string;
@@ -15,6 +15,7 @@ interface QuoteAttrs {
15
15
  amountCrypto: number;
16
16
  liquidityProvider: LiquidityProvider;
17
17
  processor: Processor;
18
+ routingDecision?: RoutingDecision;
18
19
  fees: Fees;
19
20
  assetPriceInUSD: number;
20
21
  actualAssetPriceInUSD?: number;
@@ -61,6 +62,7 @@ interface QuoteDoc extends Document {
61
62
  amountCrypto: number;
62
63
  liquidityProvider: LiquidityProvider;
63
64
  processor: Processor;
65
+ routingDecision?: RoutingDecision;
64
66
  createdAt: Date;
65
67
  timeInForceStartsAt?: Date;
66
68
  timeInForceEndsAt?: Date;
@@ -55,6 +55,9 @@ const buildQuote = (mongoose) => {
55
55
  type: String,
56
56
  required: true,
57
57
  },
58
+ routingDecision: {
59
+ type: Object,
60
+ },
58
61
  liquidityProvider: {
59
62
  type: String,
60
63
  required: true,
@@ -55,6 +55,7 @@ interface RioSettingsAttrs {
55
55
  };
56
56
  fxTradingPolicies?: FXTradingPolicies;
57
57
  processorRouting?: ProcessorRoutingConfig;
58
+ requireExplicitPayinEnablement?: boolean;
58
59
  TVFXDataProvider: {
59
60
  [key in Fiat]: TVFXDataProvider;
60
61
  };
@@ -149,6 +150,7 @@ interface RioSettingsDoc extends mongoose.Document {
149
150
  };
150
151
  fxTradingPolicies?: FXTradingPolicies;
151
152
  processorRouting?: ProcessorRoutingConfig;
153
+ requireExplicitPayinEnablement?: boolean;
152
154
  TVFXDataProvider: {
153
155
  [key in Fiat]: TVFXDataProvider;
154
156
  };
@@ -59,6 +59,9 @@ const buildRioSettings = (mongoose) => {
59
59
  processorRouting: {
60
60
  type: Object,
61
61
  },
62
+ requireExplicitPayinEnablement: {
63
+ type: Boolean,
64
+ },
62
65
  TVFXDataProvider: {
63
66
  type: Object,
64
67
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riocrypto/common-server",
3
- "version": "1.0.2889",
3
+ "version": "1.0.2892",
4
4
  "description": "",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -28,7 +28,7 @@
28
28
  "@google-cloud/secret-manager": "^5.6.0",
29
29
  "@google-cloud/storage": "^7.19.0",
30
30
  "@hyperdx/node-opentelemetry": "^0.10.3",
31
- "@riocrypto/common": "1.0.2702",
31
+ "@riocrypto/common": "1.0.2706",
32
32
  "@slack/web-api": "^7.15.0",
33
33
  "@types/express": "^4.17.25",
34
34
  "axios": "1.18.1",