@casual-simulation/aux-records 3.8.1 → 3.10.2
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.
- package/AIChatInterface.d.ts +7 -43
- package/AIChatInterface.js +8 -6
- package/AIChatInterface.js.map +1 -1
- package/AIController.js +44 -49
- package/AIController.js.map +1 -1
- package/AIOpenAIRealtimeInterface.d.ts +1 -1
- package/AnthropicAIChatInterface.js +4 -4
- package/AnthropicAIChatInterface.js.map +1 -1
- package/AuthController.d.ts +78 -10
- package/AuthController.js +230 -166
- package/AuthController.js.map +1 -1
- package/AuthStore.d.ts +317 -4
- package/BigIntPatch.d.ts +1 -0
- package/BigIntPatch.js +24 -0
- package/BigIntPatch.js.map +1 -0
- package/BlockadeLabsGenerateSkyboxInterface.js +4 -4
- package/BlockadeLabsGenerateSkyboxInterface.js.map +1 -1
- package/CachingConfigStore.d.ts +8 -1
- package/CachingConfigStore.js +50 -0
- package/CachingConfigStore.js.map +1 -1
- package/ComIdConfig.d.ts +132 -86
- package/ComIdConfig.js +5 -5
- package/ComIdConfig.js.map +1 -1
- package/ConfigurationStore.d.ts +1393 -3
- package/ConfigurationStore.js +49 -0
- package/ConfigurationStore.js.map +1 -1
- package/DataRecordsController.js +28 -28
- package/DataRecordsController.js.map +1 -1
- package/EventRecordsController.js +9 -9
- package/EventRecordsController.js.map +1 -1
- package/FileRecordsController.js +18 -17
- package/FileRecordsController.js.map +1 -1
- package/GoogleAIChatInterface.js +4 -4
- package/GoogleAIChatInterface.js.map +1 -1
- package/HttpTestUtils.d.ts +48 -0
- package/HttpTestUtils.js +256 -0
- package/HttpTestUtils.js.map +1 -0
- package/LivekitController.js +2 -2
- package/LivekitController.js.map +1 -1
- package/LoomController.js +3 -4
- package/LoomController.js.map +1 -1
- package/MemoryStore.d.ts +60 -7
- package/MemoryStore.js +427 -59
- package/MemoryStore.js.map +1 -1
- package/MetricsStore.d.ts +11 -0
- package/ModerationConfiguration.d.ts +11 -85
- package/ModerationConfiguration.js +17 -17
- package/ModerationConfiguration.js.map +1 -1
- package/ModerationController.js +9 -11
- package/ModerationController.js.map +1 -1
- package/OpenAIChatInterface.js +8 -13
- package/OpenAIChatInterface.js.map +1 -1
- package/OpenAIImageInterface.js +4 -5
- package/OpenAIImageInterface.js.map +1 -1
- package/PolicyController.js +66 -73
- package/PolicyController.js.map +1 -1
- package/PolicyStore.d.ts +59 -33
- package/PolicyStore.js +35 -1
- package/PolicyStore.js.map +1 -1
- package/PrivoClient.d.ts +3 -1
- package/PrivoClient.js +2 -4
- package/PrivoClient.js.map +1 -1
- package/PrivoConfiguration.d.ts +6 -72
- package/PrivoConfiguration.js +30 -31
- package/PrivoConfiguration.js.map +1 -1
- package/README.md +276 -2
- package/RateLimitController.js +2 -2
- package/RateLimitController.js.map +1 -1
- package/RecordsClient.d.ts +3 -1
- package/RecordsClient.js +6 -6
- package/RecordsClient.js.map +1 -1
- package/RecordsController.d.ts +145 -3
- package/RecordsController.js +399 -61
- package/RecordsController.js.map +1 -1
- package/RecordsServer.d.ts +1932 -1109
- package/RecordsStore.d.ts +99 -14
- package/RecordsStore.js +8 -10
- package/RecordsStore.js.map +1 -1
- package/ServerConfig.d.ts +698 -9867
- package/ServerConfig.js +457 -377
- package/ServerConfig.js.map +1 -1
- package/SloydInterface.js +1 -1
- package/SloydInterface.js.map +1 -1
- package/StabilityAIImageInterface.js +6 -9
- package/StabilityAIImageInterface.js.map +1 -1
- package/StripeInterface.d.ts +431 -287
- package/StripeInterface.js +21 -1
- package/StripeInterface.js.map +1 -1
- package/SubscriptionConfigBuilder.d.ts +10 -1
- package/SubscriptionConfigBuilder.js +72 -41
- package/SubscriptionConfigBuilder.js.map +1 -1
- package/SubscriptionConfiguration.d.ts +606 -6334
- package/SubscriptionConfiguration.js +460 -277
- package/SubscriptionConfiguration.js.map +1 -1
- package/SubscriptionController.d.ts +677 -4
- package/SubscriptionController.js +2986 -186
- package/SubscriptionController.js.map +1 -1
- package/SystemNotificationMessenger.d.ts +14 -70
- package/SystemNotificationMessenger.js +17 -20
- package/SystemNotificationMessenger.js.map +1 -1
- package/TestUtils.d.ts +18 -3
- package/TestUtils.js +84 -8
- package/TestUtils.js.map +1 -1
- package/TypeUtils.d.ts +991 -0
- package/TypeUtils.js +2 -0
- package/TypeUtils.js.map +1 -0
- package/Utils.d.ts +59 -0
- package/Utils.js +507 -3
- package/Utils.js.map +1 -1
- package/Validations.d.ts +24 -108
- package/Validations.js +62 -45
- package/Validations.js.map +1 -1
- package/ViewTemplateRenderer.d.ts +39 -0
- package/ViewTemplateRenderer.js +19 -0
- package/ViewTemplateRenderer.js.map +1 -0
- package/contracts/ContractRecordsController.d.ts +58 -0
- package/contracts/ContractRecordsController.js +144 -0
- package/contracts/ContractRecordsController.js.map +1 -0
- package/contracts/ContractRecordsStore.d.ts +285 -0
- package/contracts/ContractRecordsStore.js +19 -0
- package/contracts/ContractRecordsStore.js.map +1 -0
- package/contracts/MemoryContractRecordsStore.d.ts +27 -0
- package/contracts/MemoryContractRecordsStore.js +124 -0
- package/contracts/MemoryContractRecordsStore.js.map +1 -0
- package/contracts/index.d.ts +4 -0
- package/contracts/index.js +21 -0
- package/contracts/index.js.map +1 -0
- package/crud/CrudHelpers.d.ts +25 -26
- package/crud/CrudHelpers.js +1 -1
- package/crud/CrudHelpers.js.map +1 -1
- package/crud/CrudRecordsController.js +13 -16
- package/crud/CrudRecordsController.js.map +1 -1
- package/crud/CrudRecordsControllerTests.d.ts +2 -2
- package/crud/CrudRecordsControllerTests.js +605 -580
- package/crud/CrudRecordsControllerTests.js.map +1 -1
- package/crud/MemoryCrudRecordsStore.js +1 -2
- package/crud/MemoryCrudRecordsStore.js.map +1 -1
- package/crud/sub/MemorySubCrudRecordsStore.js +4 -6
- package/crud/sub/MemorySubCrudRecordsStore.js.map +1 -1
- package/crud/sub/SubCrudRecordsController.js +8 -8
- package/crud/sub/SubCrudRecordsController.js.map +1 -1
- package/database/DatabaseRecordsController.js +1 -2
- package/database/DatabaseRecordsController.js.map +1 -1
- package/database/SqliteDatabaseInterface.js +1 -2
- package/database/SqliteDatabaseInterface.js.map +1 -1
- package/dns/DNSDomainNameValidator.d.ts +11 -0
- package/dns/DNSDomainNameValidator.js +59 -0
- package/dns/DNSDomainNameValidator.js.map +1 -0
- package/dns/DomainNameValidator.d.ts +36 -0
- package/dns/DomainNameValidator.js +19 -0
- package/dns/DomainNameValidator.js.map +1 -0
- package/dns/index.d.ts +3 -0
- package/dns/index.js +20 -0
- package/dns/index.js.map +1 -0
- package/financial/FinancialController.d.ts +272 -0
- package/financial/FinancialController.js +762 -0
- package/financial/FinancialController.js.map +1 -0
- package/financial/FinancialInterface.d.ts +352 -0
- package/financial/FinancialInterface.js +642 -0
- package/financial/FinancialInterface.js.map +1 -0
- package/financial/FinancialStore.d.ts +186 -0
- package/financial/FinancialStore.js +19 -0
- package/financial/FinancialStore.js.map +1 -0
- package/financial/MemoryFinancialInterface.d.ts +23 -0
- package/financial/MemoryFinancialInterface.js +592 -0
- package/financial/MemoryFinancialInterface.js.map +1 -0
- package/financial/TigerBeetleFinancialInterface.d.ts +46 -0
- package/financial/TigerBeetleFinancialInterface.js +109 -0
- package/financial/TigerBeetleFinancialInterface.js.map +1 -0
- package/financial/TigerBeetleTestUtils.d.ts +40 -0
- package/financial/TigerBeetleTestUtils.js +185 -0
- package/financial/TigerBeetleTestUtils.js.map +1 -0
- package/financial/Types.d.ts +1 -0
- package/financial/Types.js +801 -0
- package/financial/Types.js.map +1 -0
- package/financial/index.d.ts +6 -0
- package/financial/index.js +24 -0
- package/financial/index.js.map +1 -0
- package/index.d.ts +4 -0
- package/index.js +3 -0
- package/index.js.map +1 -1
- package/notifications/NotificationRecordsController.js +14 -14
- package/notifications/NotificationRecordsController.js.map +1 -1
- package/notifications/WebPushInterface.d.ts +24 -155
- package/notifications/WebPushInterface.js +2 -2
- package/notifications/WebPushInterface.js.map +1 -1
- package/package.json +72 -70
- package/packages/version/MemoryPackageVersionRecordsStore.js +12 -15
- package/packages/version/MemoryPackageVersionRecordsStore.js.map +1 -1
- package/packages/version/PackageVersionRecordsController.d.ts +19 -0
- package/packages/version/PackageVersionRecordsController.js +102 -22
- package/packages/version/PackageVersionRecordsController.js.map +1 -1
- package/packages/version/PackageVersionRecordsStore.js +6 -8
- package/packages/version/PackageVersionRecordsStore.js.map +1 -1
- package/purchasable-items/MemoryPurchasableItemRecordsStore.d.ts +7 -0
- package/purchasable-items/MemoryPurchasableItemRecordsStore.js +33 -0
- package/purchasable-items/MemoryPurchasableItemRecordsStore.js.map +1 -0
- package/purchasable-items/PurchasableItemRecordsController.d.ts +16 -0
- package/purchasable-items/PurchasableItemRecordsController.js +72 -0
- package/purchasable-items/PurchasableItemRecordsController.js.map +1 -0
- package/purchasable-items/PurchasableItemRecordsStore.d.ts +66 -0
- package/purchasable-items/PurchasableItemRecordsStore.js +2 -0
- package/purchasable-items/PurchasableItemRecordsStore.js.map +1 -0
- package/purchasable-items/index.d.ts +4 -0
- package/purchasable-items/index.js +21 -0
- package/purchasable-items/index.js.map +1 -0
- package/queue/MemoryQueue.js +1 -1
- package/queue/MemoryQueue.js.map +1 -1
- package/search/MemorySearchInterface.js +1 -2
- package/search/MemorySearchInterface.js.map +1 -1
- package/search/SearchRecordsController.d.ts +66 -244
- package/search/SearchRecordsController.js +35 -36
- package/search/SearchRecordsController.js.map +1 -1
- package/search/SearchSyncProcessor.d.ts +7 -83
- package/search/TypesenseSearchInterface.js +8 -11
- package/search/TypesenseSearchInterface.js.map +1 -1
- package/tracing/TracingDecorators.js +5 -8
- package/tracing/TracingDecorators.js.map +1 -1
- package/webhooks/WebhookEnvironment.d.ts +36 -560
- package/webhooks/WebhookEnvironment.js +1 -1
- package/webhooks/WebhookEnvironment.js.map +1 -1
- package/webhooks/WebhookRecordsController.js +14 -16
- package/webhooks/WebhookRecordsController.js.map +1 -1
- package/websockets/MemoryLockStore.d.ts +18 -0
- package/websockets/MemoryLockStore.js +2 -0
- package/websockets/MemoryLockStore.js.map +1 -0
- package/websockets/MemoryTempInstRecordsStore.js +15 -27
- package/websockets/MemoryTempInstRecordsStore.js.map +1 -1
- package/websockets/MemoryWebsocketConnectionStore.js +6 -8
- package/websockets/MemoryWebsocketConnectionStore.js.map +1 -1
- package/websockets/MemoryWebsocketMessenger.js +1 -2
- package/websockets/MemoryWebsocketMessenger.js.map +1 -1
- package/websockets/SplitInstRecordsStore.js +2 -2
- package/websockets/SplitInstRecordsStore.js.map +1 -1
- package/websockets/WebsocketController.d.ts +5 -0
- package/websockets/WebsocketController.js +122 -83
- package/websockets/WebsocketController.js.map +1 -1
- package/RecordsServer.js +0 -6435
- package/RecordsServer.js.map +0 -1
|
@@ -5,22 +5,48 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
5
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
};
|
|
7
7
|
import { INVALID_KEY_ERROR_MESSAGE } from './AuthController';
|
|
8
|
-
import { STRIPE_EVENT_INVOICE_PAID_SCHEMA } from './StripeInterface';
|
|
8
|
+
import { STRIPE_EVENT_ACCOUNT_UPDATED_SCHEMA, STRIPE_EVENT_CHECKOUT_SESSION_SCHEMA, STRIPE_EVENT_INVOICE_PAID_SCHEMA, } from './StripeInterface';
|
|
9
9
|
import { isActiveSubscription } from './Utils';
|
|
10
|
+
import { getContractFeatures, getPurchasableItemsFeatures, } from './SubscriptionConfiguration';
|
|
10
11
|
import { traced } from './tracing/TracingDecorators';
|
|
11
12
|
import { SpanStatusCode, trace } from '@opentelemetry/api';
|
|
12
|
-
import { isSuperUserRole } from '@casual-simulation/aux-common';
|
|
13
|
+
import { failure, genericResult, isFailure, isSuccess, isSuperUserRole, logError, success, wrap, } from '@casual-simulation/aux-common';
|
|
13
14
|
const TRACE_NAME = 'SubscriptionController';
|
|
15
|
+
import { ADMIN_ROLE_NAME, fromBase64String, toBase64String, } from '@casual-simulation/aux-common';
|
|
16
|
+
import { v4 as uuid } from 'uuid';
|
|
17
|
+
import { hashHighEntropyPasswordWithSalt } from '@casual-simulation/crypto';
|
|
18
|
+
import { randomBytes } from 'tweetnacl';
|
|
19
|
+
import { fromByteArray } from 'base64-js';
|
|
20
|
+
import { ACCOUNT_IDS, ACCOUNT_NAMES, AMOUNT_MAX, convertBetweenLedgers, CURRENCIES, CurrencyCodes, getAccountBalance, getLiquidityAccountByLedger, LEDGERS, TransferCodes, } from './financial';
|
|
21
|
+
import { TransferFlags } from 'tigerbeetle-node';
|
|
22
|
+
/**
|
|
23
|
+
* The number of bytes that the access key secret should be.
|
|
24
|
+
*/
|
|
25
|
+
export const ACCESS_KEY_SECRET_BYTE_LENGTH = 16; // 128-bit
|
|
26
|
+
/**
|
|
27
|
+
* The number of bytes that the access key ID should be.
|
|
28
|
+
*/
|
|
29
|
+
export const ACCESS_KEY_ID_BYTE_LENGTH = 16; // 128-bit
|
|
30
|
+
/**
|
|
31
|
+
* The number of seconds to wait before a Stripe payout transfer times out.
|
|
32
|
+
*/
|
|
33
|
+
export const STRIPE_PAYOUT_TIMEOUT_SECONDS = 60 * 5; // 5 minutes
|
|
14
34
|
/**
|
|
15
35
|
* Defines a class that is able to handle subscriptions.
|
|
16
36
|
*/
|
|
17
37
|
export class SubscriptionController {
|
|
18
|
-
constructor(stripe, auth, authStore, recordsStore, config) {
|
|
38
|
+
constructor(stripe, auth, authStore, recordsStore, config, policies, policyStore, purchasableItems, financialController, financialStore, contractRecords) {
|
|
19
39
|
this._stripe = stripe;
|
|
20
40
|
this._auth = auth;
|
|
21
41
|
this._authStore = authStore;
|
|
22
42
|
this._recordsStore = recordsStore;
|
|
23
43
|
this._config = config;
|
|
44
|
+
this._policies = policies;
|
|
45
|
+
this._policyStore = policyStore;
|
|
46
|
+
this._purchasableItems = purchasableItems;
|
|
47
|
+
this._financialController = financialController;
|
|
48
|
+
this._financialStore = financialStore;
|
|
49
|
+
this._contractRecords = contractRecords;
|
|
24
50
|
}
|
|
25
51
|
async _getConfig() {
|
|
26
52
|
return await this._config.getSubscriptionConfiguration();
|
|
@@ -30,7 +56,6 @@ export class SubscriptionController {
|
|
|
30
56
|
* @param request
|
|
31
57
|
*/
|
|
32
58
|
async getSubscriptionStatus(request) {
|
|
33
|
-
var _a, _b;
|
|
34
59
|
if (!this._stripe) {
|
|
35
60
|
return {
|
|
36
61
|
success: false,
|
|
@@ -74,6 +99,10 @@ export class SubscriptionController {
|
|
|
74
99
|
};
|
|
75
100
|
}
|
|
76
101
|
const keyResult = await this._auth.validateSessionKey(request.sessionKey);
|
|
102
|
+
let accountBalances = success({
|
|
103
|
+
usd: undefined,
|
|
104
|
+
credits: undefined,
|
|
105
|
+
});
|
|
77
106
|
let customerId;
|
|
78
107
|
let role;
|
|
79
108
|
if (keyResult.success === false) {
|
|
@@ -93,6 +122,10 @@ export class SubscriptionController {
|
|
|
93
122
|
const user = await this._authStore.findUser(request.userId);
|
|
94
123
|
customerId = user.stripeCustomerId;
|
|
95
124
|
role = 'user';
|
|
125
|
+
accountBalances =
|
|
126
|
+
await this._financialController.getAccountBalances({
|
|
127
|
+
userId: request.userId,
|
|
128
|
+
});
|
|
96
129
|
}
|
|
97
130
|
else if (request.studioId) {
|
|
98
131
|
const assignments = await this._recordsStore.listStudioAssignments(request.studioId, {
|
|
@@ -111,19 +144,28 @@ export class SubscriptionController {
|
|
|
111
144
|
const studio = await this._recordsStore.getStudioById(request.studioId);
|
|
112
145
|
customerId = studio.stripeCustomerId;
|
|
113
146
|
role = 'studio';
|
|
147
|
+
accountBalances =
|
|
148
|
+
await this._financialController.getAccountBalances({
|
|
149
|
+
studioId: request.studioId,
|
|
150
|
+
});
|
|
114
151
|
}
|
|
115
152
|
}
|
|
153
|
+
if (isFailure(accountBalances)) {
|
|
154
|
+
logError(accountBalances.error, '[SubscriptionController] [getSubscriptionStatus] Failed to get account balances:');
|
|
155
|
+
return genericResult(accountBalances);
|
|
156
|
+
}
|
|
116
157
|
// const user = await this._authStore.findUser(keyResult.userId);
|
|
117
158
|
// let customerId = user.stripeCustomerId;
|
|
118
159
|
if (!customerId) {
|
|
119
160
|
const config = await this._getConfig();
|
|
120
161
|
return {
|
|
121
162
|
success: true,
|
|
122
|
-
userId:
|
|
163
|
+
userId: request.userId ?? keyResult.userId,
|
|
123
164
|
studioId: request.studioId,
|
|
124
165
|
publishableKey: this._stripe.publishableKey,
|
|
125
166
|
subscriptions: [],
|
|
126
167
|
purchasableSubscriptions: await this._getPurchasableSubscriptions(role, config),
|
|
168
|
+
accountBalances: accountBalances.value,
|
|
127
169
|
};
|
|
128
170
|
}
|
|
129
171
|
const listResult = await this._stripe.listActiveSubscriptionsForCustomer(customerId);
|
|
@@ -134,7 +176,7 @@ export class SubscriptionController {
|
|
|
134
176
|
return (sub.eligibleProducts &&
|
|
135
177
|
sub.eligibleProducts.some((p) => p === item.price.product.id));
|
|
136
178
|
});
|
|
137
|
-
const featureList = subscriptionInfo
|
|
179
|
+
const featureList = subscriptionInfo?.featureList;
|
|
138
180
|
return {
|
|
139
181
|
active: s.status === 'active',
|
|
140
182
|
statusCode: s.status,
|
|
@@ -157,17 +199,18 @@ export class SubscriptionController {
|
|
|
157
199
|
: await this._getPurchasableSubscriptions(role, config);
|
|
158
200
|
return {
|
|
159
201
|
success: true,
|
|
160
|
-
userId:
|
|
202
|
+
userId: request.userId ?? keyResult.userId,
|
|
161
203
|
studioId: request.studioId,
|
|
162
204
|
publishableKey: this._stripe.publishableKey,
|
|
163
205
|
subscriptions,
|
|
164
206
|
purchasableSubscriptions,
|
|
207
|
+
accountBalances: accountBalances.value,
|
|
165
208
|
};
|
|
166
209
|
}
|
|
167
210
|
catch (err) {
|
|
168
211
|
const span = trace.getActiveSpan();
|
|
169
|
-
span
|
|
170
|
-
span
|
|
212
|
+
span?.recordException(err);
|
|
213
|
+
span?.setStatus({ code: SpanStatusCode.ERROR });
|
|
171
214
|
console.error('[SubscriptionController] An error occurred while getting subscription status:', err);
|
|
172
215
|
return {
|
|
173
216
|
success: false,
|
|
@@ -176,6 +219,136 @@ export class SubscriptionController {
|
|
|
176
219
|
};
|
|
177
220
|
}
|
|
178
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Gets the account balances for the user/studio/contract.
|
|
224
|
+
* @param request
|
|
225
|
+
*/
|
|
226
|
+
async getBalances(request) {
|
|
227
|
+
if (!this._financialController) {
|
|
228
|
+
return failure({
|
|
229
|
+
errorCode: 'not_supported',
|
|
230
|
+
errorMessage: 'This feature is not supported.',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const authorizationResult = await this._checkAuthorizationForFilter(request.filter, request.userId, request.userRole);
|
|
234
|
+
if (isFailure(authorizationResult)) {
|
|
235
|
+
return authorizationResult;
|
|
236
|
+
}
|
|
237
|
+
return await this._financialController.getAccountBalances(request.filter);
|
|
238
|
+
}
|
|
239
|
+
async _checkAuthorizationForFilter(filter, userId, userRole) {
|
|
240
|
+
if (!this._financialController) {
|
|
241
|
+
return failure({
|
|
242
|
+
errorCode: 'not_supported',
|
|
243
|
+
errorMessage: 'This feature is not supported.',
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
// Check if the user has permission to access this account
|
|
247
|
+
if (!isSuperUserRole(userRole)) {
|
|
248
|
+
// Users can only access their own accounts
|
|
249
|
+
if ('userId' in filter && filter.userId) {
|
|
250
|
+
if (filter.userId !== userId) {
|
|
251
|
+
return failure({
|
|
252
|
+
errorCode: 'not_authorized',
|
|
253
|
+
errorMessage: 'You are not authorized to perform this action.',
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
else if ('studioId' in filter && filter.studioId) {
|
|
258
|
+
const assignments = await this._recordsStore.listStudioAssignments(filter.studioId, {
|
|
259
|
+
role: 'admin',
|
|
260
|
+
});
|
|
261
|
+
const userAssignment = assignments.find((a) => a.userId === userId);
|
|
262
|
+
if (!userAssignment || userAssignment.role !== 'admin') {
|
|
263
|
+
return failure({
|
|
264
|
+
errorCode: 'not_authorized',
|
|
265
|
+
errorMessage: 'You are not authorized to perform this action.',
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
else if ('contractId' in filter && filter.contractId) {
|
|
270
|
+
const contract = await this._contractRecords.getItemById(filter.contractId);
|
|
271
|
+
if (!contract) {
|
|
272
|
+
return failure({
|
|
273
|
+
errorCode: 'not_found',
|
|
274
|
+
errorMessage: 'The contract was not found.',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
// Holding and issuing users can read contract accounts by default.
|
|
278
|
+
// Other users need an explicit check
|
|
279
|
+
if (contract.contract.holdingUserId !== userId &&
|
|
280
|
+
contract.contract.issuingUserId !== userId) {
|
|
281
|
+
const context = await this._policies.constructAuthorizationContext({
|
|
282
|
+
recordKeyOrRecordName: contract.recordName,
|
|
283
|
+
userId: userId,
|
|
284
|
+
userRole: userRole,
|
|
285
|
+
});
|
|
286
|
+
if (context.success === false) {
|
|
287
|
+
return failure(context);
|
|
288
|
+
}
|
|
289
|
+
const authorization = await this._policies.authorizeSubject(context, {
|
|
290
|
+
action: 'read',
|
|
291
|
+
resourceKind: 'contract',
|
|
292
|
+
resourceId: contract.contract.address,
|
|
293
|
+
subjectType: 'user',
|
|
294
|
+
subjectId: userId,
|
|
295
|
+
markers: contract.contract.markers,
|
|
296
|
+
});
|
|
297
|
+
if (authorization.success === false) {
|
|
298
|
+
return failure(authorization);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return success();
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Lists the transfers for the given account.
|
|
307
|
+
* @param request The request.
|
|
308
|
+
*/
|
|
309
|
+
async listAccountTransfers(request) {
|
|
310
|
+
if (!this._financialController) {
|
|
311
|
+
return failure({
|
|
312
|
+
errorCode: 'not_supported',
|
|
313
|
+
errorMessage: 'This feature is not supported.',
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
// Get the account details to verify it exists and get permissions info
|
|
317
|
+
const accountDetailsResult = await this._financialController.getAccountDetails(request.accountId);
|
|
318
|
+
if (isFailure(accountDetailsResult)) {
|
|
319
|
+
return accountDetailsResult;
|
|
320
|
+
}
|
|
321
|
+
const { account, financialAccount } = accountDetailsResult.value;
|
|
322
|
+
const authorizationResult = await this._checkAuthorizationForFilter(financialAccount, request.userId, request.userRole);
|
|
323
|
+
if (isFailure(authorizationResult)) {
|
|
324
|
+
return authorizationResult;
|
|
325
|
+
}
|
|
326
|
+
// Get the transfers for this account
|
|
327
|
+
const transfersResult = await this._financialController.listTransfers(request.accountId);
|
|
328
|
+
if (isFailure(transfersResult)) {
|
|
329
|
+
return transfersResult;
|
|
330
|
+
}
|
|
331
|
+
const transfers = transfersResult.value;
|
|
332
|
+
// Map Transfer objects to AccountTransfer objects
|
|
333
|
+
const accountTransfers = transfers.map((transfer) => ({
|
|
334
|
+
id: transfer.id.toString(),
|
|
335
|
+
amount: transfer.amount,
|
|
336
|
+
debitAccountId: transfer.debit_account_id.toString(),
|
|
337
|
+
creditAccountId: transfer.credit_account_id.toString(),
|
|
338
|
+
pending: (transfer.flags & TransferFlags.pending) !== 0,
|
|
339
|
+
code: transfer.code,
|
|
340
|
+
timeMs: Number(transfer.timestamp / 1000000n), // Convert nanoseconds to milliseconds
|
|
341
|
+
transactionId: transfer.user_data_128 !== 0n
|
|
342
|
+
? transfer.user_data_128.toString()
|
|
343
|
+
: undefined,
|
|
344
|
+
note: charactarizeTransfer(transfer),
|
|
345
|
+
}));
|
|
346
|
+
return success({
|
|
347
|
+
accountDetails: financialAccount,
|
|
348
|
+
account: this._financialController.convertToAccountBalance(account),
|
|
349
|
+
transfers: accountTransfers,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
179
352
|
/**
|
|
180
353
|
* Attempts to update the subscription for the given user.
|
|
181
354
|
* @param request The request to update the subscription.
|
|
@@ -280,8 +453,8 @@ export class SubscriptionController {
|
|
|
280
453
|
}
|
|
281
454
|
catch (err) {
|
|
282
455
|
const span = trace.getActiveSpan();
|
|
283
|
-
span
|
|
284
|
-
span
|
|
456
|
+
span?.recordException(err);
|
|
457
|
+
span?.setStatus({ code: SpanStatusCode.ERROR });
|
|
285
458
|
console.error('[SubscriptionController] An error occurred while updating a subscription:', err);
|
|
286
459
|
return {
|
|
287
460
|
success: false,
|
|
@@ -292,10 +465,9 @@ export class SubscriptionController {
|
|
|
292
465
|
}
|
|
293
466
|
_getPurchasableSubscriptionsForRole(role, config) {
|
|
294
467
|
return config.subscriptions.filter((s) => {
|
|
295
|
-
|
|
296
|
-
const
|
|
297
|
-
const
|
|
298
|
-
const isStudioOnly = (_c = s.studioOnly) !== null && _c !== void 0 ? _c : false;
|
|
468
|
+
const isPurchasable = s.purchasable ?? true;
|
|
469
|
+
const isUserOnly = s.userOnly ?? false;
|
|
470
|
+
const isStudioOnly = s.studioOnly ?? false;
|
|
299
471
|
const matchesRole = (isUserOnly && role === 'user') ||
|
|
300
472
|
(isStudioOnly && role === 'studio') ||
|
|
301
473
|
(!isUserOnly && !isStudioOnly);
|
|
@@ -354,7 +526,6 @@ export class SubscriptionController {
|
|
|
354
526
|
* Returns a link that the user can be redirected to to initiate a purchase of the subscription.
|
|
355
527
|
*/
|
|
356
528
|
async createManageSubscriptionLink(request) {
|
|
357
|
-
var _a;
|
|
358
529
|
if (!this._stripe) {
|
|
359
530
|
return {
|
|
360
531
|
success: false,
|
|
@@ -537,7 +708,7 @@ export class SubscriptionController {
|
|
|
537
708
|
if (hasSubscription) {
|
|
538
709
|
console.log(`[SubscriptionController] [createManageSubscriptionLink] Customer has a managable subscription. Creating a portal session.`);
|
|
539
710
|
const session = await this._stripe.createPortalSession({
|
|
540
|
-
...(
|
|
711
|
+
...(config.portalConfig ?? {}),
|
|
541
712
|
customer: customerId,
|
|
542
713
|
return_url: returnRoute(config.returnUrl, user, studio),
|
|
543
714
|
});
|
|
@@ -552,8 +723,8 @@ export class SubscriptionController {
|
|
|
552
723
|
}
|
|
553
724
|
catch (err) {
|
|
554
725
|
const span = trace.getActiveSpan();
|
|
555
|
-
span
|
|
556
|
-
span
|
|
726
|
+
span?.recordException(err);
|
|
727
|
+
span?.setStatus({ code: SpanStatusCode.ERROR });
|
|
557
728
|
console.error('[SubscriptionController] An error occurred while creating a manage subscription link:', err);
|
|
558
729
|
return {
|
|
559
730
|
success: false,
|
|
@@ -562,8 +733,296 @@ export class SubscriptionController {
|
|
|
562
733
|
};
|
|
563
734
|
}
|
|
564
735
|
}
|
|
736
|
+
/**
|
|
737
|
+
* Creates a link that the user can be redirected to in order to manage their store account.
|
|
738
|
+
* @param request The request to create the manage store account link.
|
|
739
|
+
* @returns
|
|
740
|
+
*/
|
|
741
|
+
async createManageStoreAccountLink(request) {
|
|
742
|
+
if (!this._stripe) {
|
|
743
|
+
return failure({
|
|
744
|
+
errorCode: 'not_supported',
|
|
745
|
+
errorMessage: 'This method is not supported.',
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
let studio = await this._recordsStore.getStudioById(request.studioId);
|
|
749
|
+
if (!studio) {
|
|
750
|
+
return failure({
|
|
751
|
+
errorCode: 'studio_not_found',
|
|
752
|
+
errorMessage: 'The given studio was not found.',
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
const assignments = await this._recordsStore.listStudioAssignments(studio.id, {
|
|
756
|
+
userId: request.userId,
|
|
757
|
+
role: ADMIN_ROLE_NAME,
|
|
758
|
+
});
|
|
759
|
+
if (assignments.length <= 0) {
|
|
760
|
+
return failure({
|
|
761
|
+
errorCode: 'not_authorized',
|
|
762
|
+
errorMessage: 'You are not authorized to perform this action.',
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
const config = await this._config.getSubscriptionConfiguration();
|
|
766
|
+
const features = getPurchasableItemsFeatures(config, studio.subscriptionStatus, studio.subscriptionId, 'studio', studio.subscriptionPeriodStartMs, studio.subscriptionPeriodEndMs);
|
|
767
|
+
if (!features.allowed) {
|
|
768
|
+
return failure({
|
|
769
|
+
errorCode: 'not_authorized',
|
|
770
|
+
errorMessage: 'You are not authorized to perform this action.',
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
let type = 'account_update';
|
|
774
|
+
if (!studio.stripeAccountId) {
|
|
775
|
+
console.log('[SubscriptionController] [createManageStoreAccountLink] Studio does not have a stripe account. Creating one.');
|
|
776
|
+
type = 'account_onboarding';
|
|
777
|
+
const account = await this._stripe.createAccount({
|
|
778
|
+
controller: {
|
|
779
|
+
fees: {
|
|
780
|
+
payer: 'account',
|
|
781
|
+
},
|
|
782
|
+
losses: {
|
|
783
|
+
payments: 'stripe',
|
|
784
|
+
},
|
|
785
|
+
requirement_collection: 'stripe',
|
|
786
|
+
stripe_dashboard: {
|
|
787
|
+
type: 'full',
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
metadata: {
|
|
791
|
+
studioId: studio.id,
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
console.log('[SubscriptionController] [createManageStoreAccountLink] Created account:', account.id);
|
|
795
|
+
studio = {
|
|
796
|
+
...studio,
|
|
797
|
+
stripeAccountId: account.id,
|
|
798
|
+
stripeAccountStatus: getAccountStatus(account),
|
|
799
|
+
stripeAccountRequirementsStatus: getAccountRequirementsStatus(account),
|
|
800
|
+
};
|
|
801
|
+
await this._recordsStore.updateStudio(studio);
|
|
802
|
+
}
|
|
803
|
+
if (studio.stripeAccountRequirementsStatus === 'incomplete') {
|
|
804
|
+
type = 'account_onboarding';
|
|
805
|
+
}
|
|
806
|
+
const session = await this._stripe.createAccountLink({
|
|
807
|
+
account: studio.stripeAccountId,
|
|
808
|
+
refresh_url: config.returnUrl,
|
|
809
|
+
return_url: config.returnUrl,
|
|
810
|
+
type,
|
|
811
|
+
});
|
|
812
|
+
return success({
|
|
813
|
+
url: session.url,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Creates a link that the user can be redirected to in order to manage their stripe XP account.
|
|
818
|
+
* @param request The request to create the manage xp account link.
|
|
819
|
+
*/
|
|
820
|
+
async createManageXpAccountLink(request) {
|
|
821
|
+
if (!this._stripe || !this._financialController) {
|
|
822
|
+
return failure({
|
|
823
|
+
errorCode: 'not_supported',
|
|
824
|
+
errorMessage: 'This method is not supported.',
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
let user = await this._authStore.findUser(request.userId);
|
|
828
|
+
if (!user) {
|
|
829
|
+
console.log('[SubscriptionController] [createManageXpAccountLink] User not found.');
|
|
830
|
+
return failure({
|
|
831
|
+
errorCode: 'user_not_found',
|
|
832
|
+
errorMessage: 'The user was not found.',
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
let updatedUser = false;
|
|
836
|
+
const config = await this._config.getSubscriptionConfiguration();
|
|
837
|
+
const account = await this._financialController.getOrCreateFinancialAccount({
|
|
838
|
+
userId: user.id,
|
|
839
|
+
ledger: LEDGERS.usd,
|
|
840
|
+
});
|
|
841
|
+
if (isFailure(account)) {
|
|
842
|
+
logError(account.error, `[SubscriptionController] [createManageXpAccountLink] Failed to get USD financial account for user: ${user.id}`);
|
|
843
|
+
return failure({
|
|
844
|
+
errorCode: 'server_error',
|
|
845
|
+
errorMessage: 'Failed to get financial account.',
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
if (!user.stripeAccountId) {
|
|
849
|
+
console.log('[SubscriptionController] [createManageXpAccountLink] User does not have a stripe account. Creating one.');
|
|
850
|
+
const account = await this._stripe.createAccount({
|
|
851
|
+
controller: {
|
|
852
|
+
fees: {
|
|
853
|
+
payer: 'application',
|
|
854
|
+
},
|
|
855
|
+
losses: {
|
|
856
|
+
payments: 'application',
|
|
857
|
+
},
|
|
858
|
+
requirement_collection: 'stripe',
|
|
859
|
+
stripe_dashboard: {
|
|
860
|
+
type: 'express',
|
|
861
|
+
},
|
|
862
|
+
},
|
|
863
|
+
metadata: {
|
|
864
|
+
userId: user.id,
|
|
865
|
+
},
|
|
866
|
+
});
|
|
867
|
+
console.log('[SubscriptionController] [createManageXpAccountLink] Created account:', account.id);
|
|
868
|
+
user = {
|
|
869
|
+
...user,
|
|
870
|
+
stripeAccountId: account.id,
|
|
871
|
+
stripeAccountStatus: getAccountStatus(account),
|
|
872
|
+
stripeAccountRequirementsStatus: getAccountRequirementsStatus(account),
|
|
873
|
+
};
|
|
874
|
+
updatedUser = true;
|
|
875
|
+
}
|
|
876
|
+
if (updatedUser) {
|
|
877
|
+
await this._authStore.saveUser(user);
|
|
878
|
+
}
|
|
879
|
+
const session = await this._stripe.createAccountLink({
|
|
880
|
+
account: user.stripeAccountId,
|
|
881
|
+
refresh_url: config.returnUrl,
|
|
882
|
+
return_url: config.returnUrl,
|
|
883
|
+
// We have to always use onboarding because Stripe is responsible for collecting requirements
|
|
884
|
+
type: 'account_onboarding',
|
|
885
|
+
});
|
|
886
|
+
return success({
|
|
887
|
+
url: session.url,
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Creates a link that the user can be redirected to in order to login to their stripe account.
|
|
892
|
+
* @param request The request to create the manage xp account link.
|
|
893
|
+
*/
|
|
894
|
+
async createStripeLoginLink(request) {
|
|
895
|
+
if (!this._stripe || !this._financialController) {
|
|
896
|
+
return failure({
|
|
897
|
+
errorCode: 'not_supported',
|
|
898
|
+
errorMessage: 'This method is not supported.',
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
let user = await this._authStore.findUser(request.userId);
|
|
902
|
+
if (!user) {
|
|
903
|
+
console.log('[SubscriptionController] [createStripeLoginLink] User not found.');
|
|
904
|
+
return failure({
|
|
905
|
+
errorCode: 'user_not_found',
|
|
906
|
+
errorMessage: 'The user was not found.',
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
let accountId;
|
|
910
|
+
if (request.studioId) {
|
|
911
|
+
let studio = await this._recordsStore.getStudioById(request.studioId);
|
|
912
|
+
if (!studio) {
|
|
913
|
+
return failure({
|
|
914
|
+
errorCode: 'studio_not_found',
|
|
915
|
+
errorMessage: 'The given studio was not found.',
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
const assignments = await this._recordsStore.listStudioAssignments(studio.id, {
|
|
919
|
+
userId: request.userId,
|
|
920
|
+
role: ADMIN_ROLE_NAME,
|
|
921
|
+
});
|
|
922
|
+
if (assignments.length <= 0) {
|
|
923
|
+
return failure({
|
|
924
|
+
errorCode: 'not_authorized',
|
|
925
|
+
errorMessage: 'You are not authorized to perform this action.',
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
const config = await this._config.getSubscriptionConfiguration();
|
|
929
|
+
const features = getPurchasableItemsFeatures(config, studio.subscriptionStatus, studio.subscriptionId, 'studio', studio.subscriptionPeriodStartMs, studio.subscriptionPeriodEndMs);
|
|
930
|
+
if (!features.allowed) {
|
|
931
|
+
return failure({
|
|
932
|
+
errorCode: 'not_authorized',
|
|
933
|
+
errorMessage: 'You are not authorized to perform this action.',
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
accountId = studio.stripeAccountId;
|
|
937
|
+
}
|
|
938
|
+
else {
|
|
939
|
+
accountId = user.stripeAccountId;
|
|
940
|
+
}
|
|
941
|
+
if (!accountId) {
|
|
942
|
+
return failure({
|
|
943
|
+
errorCode: 'not_found',
|
|
944
|
+
errorMessage: 'No Stripe account found.',
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
const session = await this._stripe.createLoginLink({
|
|
948
|
+
account: accountId,
|
|
949
|
+
});
|
|
950
|
+
return success({
|
|
951
|
+
url: session.url,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Creates a session that can be used to display stripe embedded components for the user.
|
|
956
|
+
* @param request The request to create the account session.
|
|
957
|
+
*/
|
|
958
|
+
async createStripeAccountSession(request) {
|
|
959
|
+
if (!this._stripe || !this._financialController) {
|
|
960
|
+
return failure({
|
|
961
|
+
errorCode: 'not_supported',
|
|
962
|
+
errorMessage: 'This method is not supported.',
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
let user = await this._authStore.findUser(request.userId);
|
|
966
|
+
if (!user) {
|
|
967
|
+
console.log('[SubscriptionController] [createStripeAccountSession] User not found.');
|
|
968
|
+
return failure({
|
|
969
|
+
errorCode: 'user_not_found',
|
|
970
|
+
errorMessage: 'The user was not found.',
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
let accountId;
|
|
974
|
+
if (request.studioId) {
|
|
975
|
+
let studio = await this._recordsStore.getStudioById(request.studioId);
|
|
976
|
+
if (!studio) {
|
|
977
|
+
return failure({
|
|
978
|
+
errorCode: 'studio_not_found',
|
|
979
|
+
errorMessage: 'The given studio was not found.',
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
const assignments = await this._recordsStore.listStudioAssignments(studio.id, {
|
|
983
|
+
userId: request.userId,
|
|
984
|
+
role: ADMIN_ROLE_NAME,
|
|
985
|
+
});
|
|
986
|
+
if (assignments.length <= 0) {
|
|
987
|
+
return failure({
|
|
988
|
+
errorCode: 'not_authorized',
|
|
989
|
+
errorMessage: 'You are not authorized to perform this action.',
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
const config = await this._config.getSubscriptionConfiguration();
|
|
993
|
+
const features = getPurchasableItemsFeatures(config, studio.subscriptionStatus, studio.subscriptionId, 'studio', studio.subscriptionPeriodStartMs, studio.subscriptionPeriodEndMs);
|
|
994
|
+
if (!features.allowed) {
|
|
995
|
+
return failure({
|
|
996
|
+
errorCode: 'not_authorized',
|
|
997
|
+
errorMessage: 'You are not authorized to perform this action.',
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
accountId = studio.stripeAccountId;
|
|
1001
|
+
}
|
|
1002
|
+
else {
|
|
1003
|
+
accountId = user.stripeAccountId;
|
|
1004
|
+
}
|
|
1005
|
+
if (!accountId) {
|
|
1006
|
+
return failure({
|
|
1007
|
+
errorCode: 'not_found',
|
|
1008
|
+
errorMessage: 'No Stripe account found for the user.',
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
const session = await this._stripe.createAccountSession({
|
|
1012
|
+
account: user.stripeAccountId,
|
|
1013
|
+
components: {
|
|
1014
|
+
payouts: {
|
|
1015
|
+
enabled: true,
|
|
1016
|
+
features: {},
|
|
1017
|
+
},
|
|
1018
|
+
},
|
|
1019
|
+
});
|
|
1020
|
+
return success({
|
|
1021
|
+
clientSecret: session.client_secret,
|
|
1022
|
+
expiresAt: session.expires_at,
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
565
1025
|
async _createCheckoutSession(request, customerId, metadata, role, user, studio) {
|
|
566
|
-
var _a;
|
|
567
1026
|
const config = await this._getConfig();
|
|
568
1027
|
const purchasableSubscriptions = this._getPurchasableSubscriptionsForRole(role, config);
|
|
569
1028
|
let sub;
|
|
@@ -600,7 +1059,7 @@ export class SubscriptionController {
|
|
|
600
1059
|
}
|
|
601
1060
|
console.log(`[SubscriptionController] [createManageSubscriptionLink] Creating Checkout Session.`);
|
|
602
1061
|
const session = await this._stripe.createCheckoutSession({
|
|
603
|
-
...(
|
|
1062
|
+
...(config.checkoutConfig ?? {}),
|
|
604
1063
|
customer: customerId,
|
|
605
1064
|
success_url: returnRoute(config.successUrl, user, studio),
|
|
606
1065
|
cancel_url: returnRoute(config.cancelUrl, user, studio),
|
|
@@ -620,228 +1079,2306 @@ export class SubscriptionController {
|
|
|
620
1079
|
};
|
|
621
1080
|
}
|
|
622
1081
|
/**
|
|
623
|
-
*
|
|
1082
|
+
* Creates a link that the user can be redirected to in order to purchase a purchasable item.
|
|
1083
|
+
* @param request The request to create the purchase item link.
|
|
624
1084
|
*/
|
|
625
|
-
async
|
|
626
|
-
var _a;
|
|
627
|
-
if (!this._stripe) {
|
|
628
|
-
return {
|
|
629
|
-
success: false,
|
|
630
|
-
errorCode: 'not_supported',
|
|
631
|
-
errorMessage: 'This method is not supported.',
|
|
632
|
-
};
|
|
633
|
-
}
|
|
1085
|
+
async createPurchaseItemLink(request) {
|
|
634
1086
|
try {
|
|
635
|
-
|
|
636
|
-
request.
|
|
1087
|
+
const context = await this._policies.constructAuthorizationContext({
|
|
1088
|
+
recordKeyOrRecordName: request.item.recordName,
|
|
1089
|
+
userId: request.userId,
|
|
1090
|
+
});
|
|
1091
|
+
if (context.success === false) {
|
|
1092
|
+
return context;
|
|
1093
|
+
}
|
|
1094
|
+
const item = await this._purchasableItems.getItemByAddress(request.item.recordName, request.item.address);
|
|
1095
|
+
if (!item) {
|
|
637
1096
|
return {
|
|
638
1097
|
success: false,
|
|
639
|
-
errorCode: '
|
|
640
|
-
errorMessage: 'The
|
|
1098
|
+
errorCode: 'item_not_found',
|
|
1099
|
+
errorMessage: 'The item could not be found.',
|
|
641
1100
|
};
|
|
642
1101
|
}
|
|
643
|
-
if (
|
|
644
|
-
|
|
1102
|
+
if (item.currency !== request.item.currency ||
|
|
1103
|
+
item.cost !== request.item.expectedCost) {
|
|
645
1104
|
return {
|
|
646
1105
|
success: false,
|
|
647
|
-
errorCode: '
|
|
648
|
-
errorMessage: 'The
|
|
1106
|
+
errorCode: 'price_does_not_match',
|
|
1107
|
+
errorMessage: 'The expected price does not match the actual price of the item.',
|
|
649
1108
|
};
|
|
650
1109
|
}
|
|
1110
|
+
const recordName = context.context.recordName;
|
|
1111
|
+
const authorization = await this._policies.authorizeUserAndInstances(context.context, {
|
|
1112
|
+
userId: request.userId,
|
|
1113
|
+
resourceKind: 'purchasableItem',
|
|
1114
|
+
resourceId: item.address,
|
|
1115
|
+
markers: item.markers,
|
|
1116
|
+
action: 'purchase',
|
|
1117
|
+
instances: request.instances,
|
|
1118
|
+
});
|
|
1119
|
+
if (authorization.success === false) {
|
|
1120
|
+
return authorization;
|
|
1121
|
+
}
|
|
1122
|
+
const metrics = await this._purchasableItems.getSubscriptionMetrics({
|
|
1123
|
+
ownerId: context.context.recordOwnerId,
|
|
1124
|
+
studioId: context.context.recordStudioId,
|
|
1125
|
+
});
|
|
651
1126
|
const config = await this._getConfig();
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
1127
|
+
const features = getPurchasableItemsFeatures(config, metrics.subscriptionStatus, metrics.subscriptionId, 'studio', metrics.currentPeriodStartMs, metrics.currentPeriodEndMs);
|
|
1128
|
+
if (!features.allowed) {
|
|
1129
|
+
console.log(`[SubscriptionController] [createPurchaseItemLink studioId: ${metrics.studioId} subscriptionId: ${metrics.subscriptionId} subscriptionStatus: ${metrics.subscriptionStatus}] Store features not allowed.`);
|
|
1130
|
+
return {
|
|
1131
|
+
success: false,
|
|
1132
|
+
errorCode: 'store_disabled',
|
|
1133
|
+
errorMessage: 'The store you are trying to purchase from is disabled.',
|
|
1134
|
+
};
|
|
657
1135
|
}
|
|
658
|
-
|
|
659
|
-
console.log(`[SubscriptionController] [
|
|
1136
|
+
if (!metrics.stripeAccountId || !metrics.stripeAccountStatus) {
|
|
1137
|
+
console.log(`[SubscriptionController] [createPurchaseItemLink studioId: ${metrics.studioId} subscriptionId: ${metrics.subscriptionId} subscriptionStatus: ${metrics.subscriptionStatus} stripeAccountId: ${metrics.stripeAccountId} stripeAccountStatus: ${metrics.stripeAccountStatus}] Store has no stripe account.`);
|
|
660
1138
|
return {
|
|
661
1139
|
success: false,
|
|
662
|
-
errorCode: '
|
|
663
|
-
errorMessage: 'The
|
|
1140
|
+
errorCode: 'store_disabled',
|
|
1141
|
+
errorMessage: 'The store you are trying to purchase from is disabled.',
|
|
664
1142
|
};
|
|
665
1143
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
const periodEndMs = subscription.current_period_end * 1000;
|
|
698
|
-
console.log(`[SubscriptionController] [handleStripeWebhook] Customer ID: ${customerId}. Subscription status: ${status}. Tier: ${tier}. Is Active: ${active}.`);
|
|
699
|
-
let user = await this._authStore.findUserByStripeCustomerId(customerId);
|
|
700
|
-
let studio;
|
|
701
|
-
if (user) {
|
|
702
|
-
await this._authStore.updateSubscriptionInfo({
|
|
703
|
-
userId: user.id,
|
|
704
|
-
subscriptionStatus: status,
|
|
705
|
-
subscriptionId: sub.id,
|
|
706
|
-
stripeSubscriptionId,
|
|
707
|
-
stripeCustomerId: customerId,
|
|
708
|
-
currentPeriodEndMs: periodEndMs,
|
|
709
|
-
currentPeriodStartMs: periodStartMs,
|
|
710
|
-
});
|
|
1144
|
+
if (metrics.stripeAccountStatus !== 'active') {
|
|
1145
|
+
console.log(`[SubscriptionController] [createPurchaseItemLink studioId: ${metrics.studioId} subscriptionId: ${metrics.subscriptionId} subscriptionStatus: ${metrics.subscriptionStatus} stripeAccountId: ${metrics.stripeAccountId} stripeAccountStatus: ${metrics.stripeAccountStatus}] Store stripe account is not active.`);
|
|
1146
|
+
return {
|
|
1147
|
+
success: false,
|
|
1148
|
+
errorCode: 'store_disabled',
|
|
1149
|
+
errorMessage: 'The store you are trying to purchase from is disabled.',
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
const limits = features.currencyLimits[item.currency];
|
|
1153
|
+
if (!limits) {
|
|
1154
|
+
console.log(`[SubscriptionController] [createPurchaseItemLink studioId: ${metrics.studioId} subscriptionId: ${metrics.subscriptionId} currency: ${request.item.currency}] Currency not supported.`);
|
|
1155
|
+
return {
|
|
1156
|
+
success: false,
|
|
1157
|
+
errorCode: 'currency_not_supported',
|
|
1158
|
+
errorMessage: 'The currency is not supported.',
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
if (item.cost !== 0 &&
|
|
1162
|
+
(item.cost < limits.minCost || item.cost > limits.maxCost)) {
|
|
1163
|
+
console.log(`[SubscriptionController] [createPurchaseItemLink studioId: ${metrics.studioId} subscriptionId: ${metrics.subscriptionId} currency: ${request.item.currency} minCost: ${limits.minCost} maxCost: ${limits.maxCost} cost: ${item.cost}] Cost not valid.`);
|
|
1164
|
+
return {
|
|
1165
|
+
success: false,
|
|
1166
|
+
errorCode: 'subscription_limit_reached',
|
|
1167
|
+
errorMessage: 'The item you are trying to purchase has a price that is not allowed.',
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
let applicationFee = 0;
|
|
1171
|
+
if (item.cost !== 0 && limits.fee) {
|
|
1172
|
+
if (limits.fee.type === 'percent') {
|
|
1173
|
+
// calculate percent when fee is between 1 - 100
|
|
1174
|
+
applicationFee = Math.ceil(item.cost * (limits.fee.percent / 100));
|
|
711
1175
|
}
|
|
712
1176
|
else {
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
subscriptionId: sub.id,
|
|
721
|
-
stripeSubscriptionId,
|
|
722
|
-
stripeCustomerId: customerId,
|
|
723
|
-
currentPeriodEndMs: periodEndMs,
|
|
724
|
-
currentPeriodStartMs: periodStartMs,
|
|
725
|
-
});
|
|
726
|
-
}
|
|
727
|
-
else {
|
|
728
|
-
console.log(`[SubscriptionController] [handleStripeWebhook] No studio found for Customer ID (${customerId})`);
|
|
1177
|
+
if (limits.fee.amount > item.cost) {
|
|
1178
|
+
console.warn(`[SubscriptionController] [createPurchaseItemLink studioId: ${metrics.studioId} subscriptionId: ${metrics.subscriptionId} currency: ${request.item.currency} fee: ${limits.fee.amount} cost: ${item.cost}] Fee greater than cost.`);
|
|
1179
|
+
return {
|
|
1180
|
+
success: false,
|
|
1181
|
+
errorCode: 'server_error',
|
|
1182
|
+
errorMessage: 'The application fee is greater than the cost of the item.',
|
|
1183
|
+
};
|
|
729
1184
|
}
|
|
1185
|
+
applicationFee = limits.fee.amount;
|
|
730
1186
|
}
|
|
731
|
-
return {
|
|
732
|
-
success: true,
|
|
733
|
-
};
|
|
734
1187
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
1188
|
+
let customerEmail = null;
|
|
1189
|
+
if (request.userId) {
|
|
1190
|
+
const user = await this._authStore.findUser(request.userId);
|
|
1191
|
+
if (!user) {
|
|
739
1192
|
return {
|
|
740
|
-
success:
|
|
1193
|
+
success: false,
|
|
1194
|
+
errorCode: 'invalid_request',
|
|
1195
|
+
errorMessage: 'The user could not be found.',
|
|
741
1196
|
};
|
|
742
1197
|
}
|
|
743
|
-
const
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1198
|
+
const roles = await this._policyStore.listRolesForUser(recordName, user.id);
|
|
1199
|
+
const hasRole = roles.some((r) => r.role === item.roleName &&
|
|
1200
|
+
(!r.expireTimeMs || r.expireTimeMs > Date.now()));
|
|
1201
|
+
if (hasRole) {
|
|
1202
|
+
return {
|
|
1203
|
+
success: false,
|
|
1204
|
+
errorCode: 'item_already_purchased',
|
|
1205
|
+
errorMessage: 'You already have the role that the item would grant.',
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
customerEmail = user.email ?? null;
|
|
1209
|
+
}
|
|
1210
|
+
const sessionId = uuid();
|
|
1211
|
+
console.log(`[SubscriptionController] [createPurchaseItemLink studioId: ${metrics.studioId} subscriptionId: ${metrics.subscriptionId} sessionId: ${sessionId} currency: ${request.item.currency} cost: ${item.cost} applicationFee: ${applicationFee}] Creating checkout session.`);
|
|
1212
|
+
const expirationSeconds = 60 * 60; // 1 hour
|
|
1213
|
+
const session = await this._stripe.createCheckoutSession({
|
|
1214
|
+
mode: 'payment',
|
|
1215
|
+
line_items: [
|
|
1216
|
+
{
|
|
1217
|
+
price_data: {
|
|
1218
|
+
currency: item.currency,
|
|
1219
|
+
unit_amount: item.cost,
|
|
1220
|
+
product_data: {
|
|
1221
|
+
name: item.name,
|
|
1222
|
+
description: item.description,
|
|
1223
|
+
images: item.imageUrls,
|
|
1224
|
+
metadata: {
|
|
1225
|
+
recordName: recordName,
|
|
1226
|
+
address: item.address,
|
|
1227
|
+
},
|
|
1228
|
+
tax_code: item.taxCode ?? undefined,
|
|
1229
|
+
},
|
|
1230
|
+
},
|
|
1231
|
+
quantity: 1,
|
|
1232
|
+
},
|
|
1233
|
+
],
|
|
1234
|
+
expires_at: Math.floor(Date.now() / 1000) + expirationSeconds,
|
|
1235
|
+
success_url: fulfillmentRoute(config.returnUrl, sessionId),
|
|
1236
|
+
cancel_url: request.returnUrl,
|
|
1237
|
+
client_reference_id: sessionId,
|
|
1238
|
+
customer_email: customerEmail,
|
|
1239
|
+
metadata: {
|
|
1240
|
+
userId: request.userId,
|
|
1241
|
+
checkoutSessionId: sessionId,
|
|
1242
|
+
},
|
|
1243
|
+
payment_intent_data: {
|
|
1244
|
+
application_fee_amount: applicationFee,
|
|
1245
|
+
},
|
|
1246
|
+
connect: {
|
|
1247
|
+
stripeAccount: metrics.stripeAccountId,
|
|
1248
|
+
},
|
|
1249
|
+
});
|
|
1250
|
+
await this._authStore.updateCheckoutSessionInfo({
|
|
1251
|
+
id: sessionId,
|
|
1252
|
+
stripeCheckoutSessionId: session.id,
|
|
1253
|
+
invoice: session.invoice
|
|
1254
|
+
? {
|
|
1255
|
+
currency: session.invoice.currency,
|
|
1256
|
+
paid: session.invoice.paid,
|
|
1257
|
+
description: session.invoice.description,
|
|
1258
|
+
status: session.invoice.status,
|
|
1259
|
+
stripeInvoiceId: session.invoice.id,
|
|
1260
|
+
stripeHostedInvoiceUrl: session.invoice.hosted_invoice_url,
|
|
1261
|
+
stripeInvoicePdfUrl: session.invoice.invoice_pdf,
|
|
1262
|
+
tax: session.invoice.tax,
|
|
1263
|
+
total: session.invoice.total,
|
|
1264
|
+
subtotal: session.invoice.subtotal,
|
|
1265
|
+
}
|
|
1266
|
+
: null,
|
|
1267
|
+
userId: request.userId,
|
|
1268
|
+
status: session.status,
|
|
1269
|
+
paymentStatus: session.payment_status,
|
|
1270
|
+
paid: session.payment_status === 'paid' ||
|
|
1271
|
+
session.payment_status === 'no_payment_required',
|
|
1272
|
+
fulfilledAtMs: null,
|
|
1273
|
+
items: [
|
|
1274
|
+
{
|
|
1275
|
+
type: 'role',
|
|
1276
|
+
recordName: recordName,
|
|
1277
|
+
purchasableItemAddress: item.address,
|
|
1278
|
+
role: item.roleName,
|
|
1279
|
+
roleGrantTimeMs: item.roleGrantTimeMs,
|
|
1280
|
+
},
|
|
1281
|
+
],
|
|
1282
|
+
});
|
|
1283
|
+
return {
|
|
1284
|
+
success: true,
|
|
1285
|
+
url: session.url,
|
|
1286
|
+
sessionId: sessionId,
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
catch (err) {
|
|
1290
|
+
console.error('[SubscriptionController] An error occurred while creating a purchase item link:', err);
|
|
1291
|
+
return {
|
|
1292
|
+
success: false,
|
|
1293
|
+
errorCode: 'server_error',
|
|
1294
|
+
errorMessage: 'A server error occurred.',
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Gets the details required for purchasing a contract.
|
|
1300
|
+
* @param request The request.
|
|
1301
|
+
*/
|
|
1302
|
+
async _getContractPurchaseDetails(request) {
|
|
1303
|
+
const context = await this._policies.constructAuthorizationContext({
|
|
1304
|
+
recordKeyOrRecordName: request.contract.recordName,
|
|
1305
|
+
userId: request.userId,
|
|
1306
|
+
});
|
|
1307
|
+
if (context.success === false) {
|
|
1308
|
+
return failure(context);
|
|
1309
|
+
}
|
|
1310
|
+
const item = await this._contractRecords.getItemByAddress(request.contract.recordName, request.contract.address);
|
|
1311
|
+
if (!item) {
|
|
1312
|
+
return failure({
|
|
1313
|
+
errorCode: 'item_not_found',
|
|
1314
|
+
errorMessage: 'The item could not be found.',
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
if (item.status !== 'pending') {
|
|
1318
|
+
return failure({
|
|
1319
|
+
errorCode: 'item_already_purchased',
|
|
1320
|
+
errorMessage: 'The contract has already been purchased.',
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
// TODO: Pull this from the contract.
|
|
1324
|
+
const currency = 'usd';
|
|
1325
|
+
const authorization = await this._policies.authorizeUserAndInstances(context.context, {
|
|
1326
|
+
userId: request.userId,
|
|
1327
|
+
resourceKind: 'contract',
|
|
1328
|
+
resourceId: item.address,
|
|
1329
|
+
markers: item.markers,
|
|
1330
|
+
action: 'purchase',
|
|
1331
|
+
instances: request.instances,
|
|
1332
|
+
});
|
|
1333
|
+
if (authorization.success === false) {
|
|
1334
|
+
return failure(authorization);
|
|
1335
|
+
}
|
|
1336
|
+
const metrics = await this._contractRecords.getSubscriptionMetrics({
|
|
1337
|
+
ownerId: context.context.recordOwnerId,
|
|
1338
|
+
studioId: context.context.recordStudioId,
|
|
1339
|
+
});
|
|
1340
|
+
const config = await this._getConfig();
|
|
1341
|
+
const features = getContractFeatures(config, metrics.subscriptionStatus, metrics.subscriptionId, metrics.subscriptionType, metrics.currentPeriodStartMs, metrics.currentPeriodEndMs);
|
|
1342
|
+
if (!features.allowed) {
|
|
1343
|
+
console.log(`[SubscriptionController] [_getContractPurchaseDetails studioId: ${metrics.studioId} subscriptionId: ${metrics.subscriptionId} subscriptionStatus: ${metrics.subscriptionStatus}] Store features not allowed.`);
|
|
1344
|
+
return failure({
|
|
1345
|
+
errorCode: 'store_disabled',
|
|
1346
|
+
errorMessage: "The account you are trying to purchase the contract for doesn't have access to contracting features.",
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
const limits = features.currencyLimits[currency];
|
|
1350
|
+
if (!limits) {
|
|
1351
|
+
console.log(`[SubscriptionController] [_getContractPurchaseDetails studioId: ${metrics.studioId} subscriptionId: ${metrics.subscriptionId} currency: ${currency}] Currency not supported.`);
|
|
1352
|
+
return failure({
|
|
1353
|
+
errorCode: 'currency_not_supported',
|
|
1354
|
+
errorMessage: 'The currency is not supported.',
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
if (item.initialValue !== 0 &&
|
|
1358
|
+
(item.initialValue < limits.minCost ||
|
|
1359
|
+
item.initialValue > limits.maxCost)) {
|
|
1360
|
+
console.log(`[SubscriptionController] [_getContractPurchaseDetails studioId: ${metrics.studioId} subscriptionId: ${metrics.subscriptionId} currency: ${currency} minCost: ${limits.minCost} maxCost: ${limits.maxCost} initialValue: ${item.initialValue}] Cost not valid.`);
|
|
1361
|
+
return failure({
|
|
1362
|
+
errorCode: 'subscription_limit_reached',
|
|
1363
|
+
errorMessage: 'The contract you are trying to purchase has a price that is not allowed.',
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
let applicationFee = 0;
|
|
1367
|
+
if (item.initialValue !== 0 && limits.fee) {
|
|
1368
|
+
if (limits.fee.type === 'percent') {
|
|
1369
|
+
// calculate percent when fee is between 1 - 100
|
|
1370
|
+
applicationFee = Math.ceil(item.initialValue * (limits.fee.percent / 100));
|
|
1371
|
+
}
|
|
1372
|
+
else {
|
|
1373
|
+
// if (limits.fee.amount > item.initialValue) {
|
|
1374
|
+
// console.warn(
|
|
1375
|
+
// `[SubscriptionController] [purchaseContract studioId: ${metrics.studioId} subscriptionId: ${metrics.subscriptionId} currency: ${currency} fee: ${limits.fee.amount} initialValue: ${item.initialValue}] Fee greater than cost.`
|
|
1376
|
+
// );
|
|
1377
|
+
// return {
|
|
1378
|
+
// success: false,
|
|
1379
|
+
// errorCode: 'server_error',
|
|
1380
|
+
// errorMessage:
|
|
1381
|
+
// 'The application fee is greater than the cost of the item.',
|
|
1382
|
+
// };
|
|
1383
|
+
// }
|
|
1384
|
+
applicationFee = limits.fee.amount;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
const totalCost = item.initialValue + applicationFee;
|
|
1388
|
+
return success({
|
|
1389
|
+
totalCost,
|
|
1390
|
+
applicationFee,
|
|
1391
|
+
item,
|
|
1392
|
+
features,
|
|
1393
|
+
metrics,
|
|
1394
|
+
limits,
|
|
1395
|
+
currency,
|
|
1396
|
+
context: context.context,
|
|
1397
|
+
authorization,
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Gets the pricing information for a contract.
|
|
1402
|
+
* @param request The request.
|
|
1403
|
+
*/
|
|
1404
|
+
async getContractPricing(request) {
|
|
1405
|
+
const details = await this._getContractPurchaseDetails(request);
|
|
1406
|
+
if (isFailure(details)) {
|
|
1407
|
+
return details;
|
|
1408
|
+
}
|
|
1409
|
+
const lineItems = [];
|
|
1410
|
+
lineItems.push({
|
|
1411
|
+
name: 'Contract',
|
|
1412
|
+
amount: details.value.item.initialValue,
|
|
1413
|
+
});
|
|
1414
|
+
if (details.value.applicationFee > 0) {
|
|
1415
|
+
lineItems.push({
|
|
1416
|
+
name: 'Application Fee',
|
|
1417
|
+
amount: details.value.applicationFee,
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
return success({
|
|
1421
|
+
total: details.value.totalCost,
|
|
1422
|
+
currency: details.value.currency,
|
|
1423
|
+
lineItems,
|
|
1424
|
+
contract: details.value.item,
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Creates a link that the user can be redirected to in order to purchase a contract.
|
|
1429
|
+
* @param request The request to purchase the contract.
|
|
1430
|
+
* @returns A promise that resolves to the result of the purchase contract operation.
|
|
1431
|
+
*/
|
|
1432
|
+
async purchaseContract(request) {
|
|
1433
|
+
try {
|
|
1434
|
+
const details = await this._getContractPurchaseDetails(request);
|
|
1435
|
+
if (isFailure(details)) {
|
|
1436
|
+
return details;
|
|
1437
|
+
}
|
|
1438
|
+
const item = details.value.item;
|
|
1439
|
+
const currency = details.value.currency;
|
|
1440
|
+
if (currency !== request.contract.currency) {
|
|
1441
|
+
return failure({
|
|
1442
|
+
errorCode: 'price_does_not_match',
|
|
1443
|
+
errorMessage: 'The expected price does not match the actual price of the contract.',
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
const recordName = details.value.context.recordName;
|
|
1447
|
+
const metrics = details.value.metrics;
|
|
1448
|
+
const config = await this._getConfig();
|
|
1449
|
+
const features = details.value.features;
|
|
1450
|
+
const limits = details.value.limits;
|
|
1451
|
+
const totalCost = details.value.totalCost;
|
|
1452
|
+
const applicationFee = details.value.applicationFee;
|
|
1453
|
+
if (totalCost !== request.contract.expectedCost) {
|
|
1454
|
+
return failure({
|
|
1455
|
+
errorCode: 'price_does_not_match',
|
|
1456
|
+
errorMessage: 'The expected price does not match the actual price of the contract.',
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
let customerEmail = null;
|
|
1460
|
+
if (request.userId) {
|
|
1461
|
+
const user = await this._authStore.findUser(request.userId);
|
|
1462
|
+
if (!user) {
|
|
1463
|
+
return failure({
|
|
1464
|
+
success: false,
|
|
1465
|
+
errorCode: 'invalid_request',
|
|
1466
|
+
errorMessage: 'The user could not be found.',
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
// const roles = await this._policyStore.listRolesForUser(
|
|
1470
|
+
// recordName,
|
|
1471
|
+
// user.id
|
|
1472
|
+
// );
|
|
1473
|
+
// const hasRole = roles.some(
|
|
1474
|
+
// (r) =>
|
|
1475
|
+
// r.role === item.roleName &&
|
|
1476
|
+
// (!r.expireTimeMs || r.expireTimeMs > Date.now())
|
|
1477
|
+
// );
|
|
1478
|
+
// if (hasRole) {
|
|
1479
|
+
// return {
|
|
1480
|
+
// success: false,
|
|
1481
|
+
// errorCode: 'item_already_purchased',
|
|
1482
|
+
// errorMessage:
|
|
1483
|
+
// 'You already have the role that the item would grant.',
|
|
1484
|
+
// };
|
|
1485
|
+
// }
|
|
1486
|
+
customerEmail = user.email ?? null;
|
|
1487
|
+
}
|
|
1488
|
+
const contractAccount = await this._financialController.getOrCreateFinancialAccount({
|
|
1489
|
+
contractId: item.id,
|
|
1490
|
+
ledger: LEDGERS.usd,
|
|
1491
|
+
});
|
|
1492
|
+
if (isFailure(contractAccount)) {
|
|
1493
|
+
logError(contractAccount.error, `[SubscriptionController] [purchaseContract] Failed to get USD financial account for contract: ${item.id}`);
|
|
1494
|
+
return failure({
|
|
1495
|
+
success: false,
|
|
1496
|
+
errorCode: 'server_error',
|
|
1497
|
+
errorMessage: 'Failed to get a financial account for the contract.',
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
const sessionId = uuid();
|
|
1501
|
+
console.log(`[SubscriptionController] [purchaseContract studioId: ${metrics.studioId} subscriptionId: ${metrics.subscriptionId} sessionId: ${sessionId} currency: ${request.contract.currency} initialValue: ${item.initialValue} applicationFee: ${applicationFee}] Creating checkout session.`);
|
|
1502
|
+
const userUsdAccount = await this._financialController.getFinancialAccount({
|
|
1503
|
+
userId: request.userId,
|
|
1504
|
+
ledger: LEDGERS.usd,
|
|
1505
|
+
});
|
|
1506
|
+
let immediateFulfillment = false;
|
|
1507
|
+
const checkoutSession = {
|
|
1508
|
+
id: sessionId,
|
|
1509
|
+
stripeCheckoutSessionId: null,
|
|
1510
|
+
invoice: null,
|
|
1511
|
+
userId: request.userId,
|
|
1512
|
+
status: 'complete',
|
|
1513
|
+
paymentStatus: 'no_payment_required',
|
|
1514
|
+
paid: false,
|
|
1515
|
+
fulfilledAtMs: null,
|
|
1516
|
+
items: [
|
|
1517
|
+
{
|
|
1518
|
+
type: 'contract',
|
|
1519
|
+
recordName: recordName,
|
|
1520
|
+
contractAddress: item.address,
|
|
1521
|
+
contractId: item.id,
|
|
1522
|
+
value: item.initialValue,
|
|
1523
|
+
},
|
|
1524
|
+
],
|
|
1525
|
+
transactionId: null,
|
|
1526
|
+
transferIds: null,
|
|
1527
|
+
shouldBeAutomaticallyFulfilled: true,
|
|
1528
|
+
};
|
|
1529
|
+
if (isFailure(userUsdAccount)) {
|
|
1530
|
+
logError(userUsdAccount.error, `[SubscriptionController] [purchaseContract] Failed to get USD financial account for user: ${request.userId}`, console.warn);
|
|
1531
|
+
}
|
|
1532
|
+
else {
|
|
1533
|
+
const balance = getAccountBalance(userUsdAccount.value.account);
|
|
1534
|
+
if (balance >= totalCost) {
|
|
1535
|
+
// try to create a transfer from the user account
|
|
1536
|
+
console.log(`[SubscriptionController] [purchaseContract accountId: ${userUsdAccount.value.account.id}] Attempting to pay out of user USD account.`);
|
|
1537
|
+
const builder = new TransactionBuilder();
|
|
1538
|
+
builder.disablePendingTransfers();
|
|
1539
|
+
builder.addContract({
|
|
1540
|
+
recordName,
|
|
1541
|
+
item,
|
|
1542
|
+
contractAccountId: contractAccount.value.account.id,
|
|
1543
|
+
debitAccountId: userUsdAccount.value.account.id,
|
|
1544
|
+
});
|
|
1545
|
+
if (applicationFee > 0) {
|
|
1546
|
+
builder.addContractApplicationFee({
|
|
1547
|
+
recordName,
|
|
1548
|
+
item,
|
|
1549
|
+
fee: applicationFee,
|
|
1550
|
+
debitAccountId: userUsdAccount.value.account.id,
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
const transferResult = await this._financialController.internalTransaction({
|
|
1554
|
+
transfers: builder.transfers,
|
|
1555
|
+
});
|
|
1556
|
+
if (isFailure(transferResult)) {
|
|
1557
|
+
logError(transferResult.error, `[SubscriptionController] [purchaseContract] Failed to pay for contract from user's USD account:`, console.warn);
|
|
1558
|
+
}
|
|
1559
|
+
else {
|
|
1560
|
+
console.log(`[SubscriptionController] [purchaseContract] Successfully paid for contract from user's USD account.`);
|
|
1561
|
+
checkoutSession.paid = true;
|
|
1562
|
+
checkoutSession.transferIds =
|
|
1563
|
+
transferResult.value.transferIds;
|
|
1564
|
+
checkoutSession.transactionId =
|
|
1565
|
+
transferResult.value.transactionId;
|
|
1566
|
+
immediateFulfillment = true;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
if (!checkoutSession.paid) {
|
|
1571
|
+
const userCreditAccount = await this._financialController.getFinancialAccount({
|
|
1572
|
+
userId: request.userId,
|
|
1573
|
+
ledger: LEDGERS.credits,
|
|
1574
|
+
});
|
|
1575
|
+
if (isFailure(userCreditAccount)) {
|
|
1576
|
+
logError(userCreditAccount.error, `[SubscriptionController] [purchaseContract] Failed to get Credit financial account for user: ${request.userId}`, console.warn);
|
|
1577
|
+
}
|
|
1578
|
+
else {
|
|
1579
|
+
const balance = getAccountBalance(userCreditAccount.value.account);
|
|
1580
|
+
if (balance >= totalCost) {
|
|
1581
|
+
// try to create a transfer from the user account
|
|
1582
|
+
console.log(`[SubscriptionController] [purchaseContract accountId: ${userCreditAccount.value.account.id}] Attempting to pay out of user credits account.`);
|
|
1583
|
+
const totalCreditCost = convertBetweenLedgers(LEDGERS.usd, LEDGERS.credits, BigInt(totalCost));
|
|
1584
|
+
if (totalCreditCost === null) {
|
|
1585
|
+
console.error(`[SubscriptionController] [purchaseContract] Failed to convert cost from USD to Credits.`);
|
|
1586
|
+
return failure({
|
|
1587
|
+
errorCode: 'server_error',
|
|
1588
|
+
errorMessage: 'Failed to convert cost from USD to Credits.',
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1591
|
+
const builder = new TransactionBuilder();
|
|
1592
|
+
builder.disablePendingTransfers();
|
|
1593
|
+
builder.addTransfer({
|
|
1594
|
+
debitAccountId: userCreditAccount.value.account.id,
|
|
1595
|
+
creditAccountId: getLiquidityAccountByLedger(userCreditAccount.value.account.ledger),
|
|
1596
|
+
currency: CURRENCIES.get(userCreditAccount.value.account.ledger),
|
|
1597
|
+
amount: totalCreditCost.value,
|
|
1598
|
+
code: TransferCodes.exchange,
|
|
1599
|
+
});
|
|
1600
|
+
builder.addContract({
|
|
1601
|
+
recordName,
|
|
1602
|
+
item,
|
|
1603
|
+
contractAccountId: contractAccount.value.account.id,
|
|
1604
|
+
debitAccountId: getLiquidityAccountByLedger(contractAccount.value.account.ledger),
|
|
1605
|
+
});
|
|
1606
|
+
if (applicationFee > 0) {
|
|
1607
|
+
builder.addContractApplicationFee({
|
|
1608
|
+
recordName,
|
|
1609
|
+
item,
|
|
1610
|
+
fee: applicationFee,
|
|
1611
|
+
debitAccountId: getLiquidityAccountByLedger(contractAccount.value.account.ledger),
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
const transferResult = await this._financialController.internalTransaction({
|
|
1615
|
+
transfers: builder.transfers,
|
|
1616
|
+
});
|
|
1617
|
+
if (isFailure(transferResult)) {
|
|
1618
|
+
logError(transferResult.error, `[SubscriptionController] [purchaseContract] Failed to pay for contract from user's credit account:`, console.warn);
|
|
1619
|
+
}
|
|
1620
|
+
else {
|
|
1621
|
+
console.log(`[SubscriptionController] [purchaseContract] Successfully paid for contract from user's credit account.`);
|
|
1622
|
+
checkoutSession.paid = true;
|
|
1623
|
+
checkoutSession.transferIds =
|
|
1624
|
+
transferResult.value.transferIds;
|
|
1625
|
+
checkoutSession.transactionId =
|
|
1626
|
+
transferResult.value.transactionId;
|
|
1627
|
+
immediateFulfillment = true;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
let url = undefined;
|
|
1633
|
+
let voidTransfers = async () => { };
|
|
1634
|
+
if (!checkoutSession.paid) {
|
|
1635
|
+
console.log(`[SubscriptionController] [purchaseContract] Attempting to pay out of Stripe.`);
|
|
1636
|
+
const expirationSeconds = 60 * 60; // 1 hour
|
|
1637
|
+
const builder = new TransactionBuilder();
|
|
1638
|
+
builder.usePendingTransfers(expirationSeconds);
|
|
1639
|
+
builder.addContract({
|
|
1640
|
+
recordName,
|
|
1641
|
+
item,
|
|
1642
|
+
contractAccountId: contractAccount.value.account.id,
|
|
1643
|
+
debitAccountId: ACCOUNT_IDS.assets_stripe,
|
|
1644
|
+
});
|
|
1645
|
+
if (applicationFee > 0) {
|
|
1646
|
+
builder.addContractApplicationFee({
|
|
1647
|
+
recordName,
|
|
1648
|
+
item,
|
|
1649
|
+
fee: applicationFee,
|
|
1650
|
+
debitAccountId: ACCOUNT_IDS.assets_stripe,
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
const transferResult = await this._financialController.internalTransaction({
|
|
1654
|
+
transfers: builder.transfers,
|
|
1655
|
+
});
|
|
1656
|
+
if (isFailure(transferResult)) {
|
|
1657
|
+
logError(transferResult.error, `[SubscriptionController] [purchaseContract] Failed to create internal transfer for contract: ${item.id}`);
|
|
1658
|
+
// TODO: Map out better error codes
|
|
1659
|
+
return failure({
|
|
1660
|
+
errorCode: 'server_error',
|
|
1661
|
+
errorMessage: 'Failed to create internal transfer for contract.',
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
voidTransfers = async () => {
|
|
1665
|
+
console.log(`[SubscriptionController] [purchaseContract] Voiding pending transfers for failed purchase.`);
|
|
1666
|
+
const voidResult = await this._financialController.completePendingTransfers({
|
|
1667
|
+
transfers: transferResult.value.transferIds,
|
|
1668
|
+
transactionId: transferResult.value.transactionId,
|
|
1669
|
+
flags: TransferFlags.void_pending_transfer,
|
|
1670
|
+
});
|
|
1671
|
+
if (isFailure(voidResult)) {
|
|
1672
|
+
logError(voidResult.error, `[SubscriptionController] [purchaseContract] Failed to void pending transfers for purchase:`);
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
const sessionResult = await wrap(() => this._stripe.createCheckoutSession({
|
|
1676
|
+
mode: 'payment',
|
|
1677
|
+
line_items: builder.lineItems,
|
|
1678
|
+
success_url: checkoutSession.shouldBeAutomaticallyFulfilled
|
|
1679
|
+
? request.successUrl
|
|
1680
|
+
: fulfillmentRoute(config.returnUrl, sessionId),
|
|
1681
|
+
cancel_url: request.returnUrl,
|
|
1682
|
+
client_reference_id: sessionId,
|
|
1683
|
+
customer_email: customerEmail,
|
|
1684
|
+
expires_at: Math.floor(Date.now() / 1000) + expirationSeconds,
|
|
1685
|
+
metadata: {
|
|
1686
|
+
userId: request.userId,
|
|
1687
|
+
checkoutSessionId: sessionId,
|
|
1688
|
+
transactionId: transferResult.value.transactionId,
|
|
1689
|
+
},
|
|
1690
|
+
payment_intent_data: {
|
|
1691
|
+
// application_fee_amount: applicationFee,
|
|
1692
|
+
transfer_group: item.id,
|
|
1693
|
+
},
|
|
1694
|
+
// connect: {
|
|
1695
|
+
// stripeAccount: metrics.,
|
|
1696
|
+
// },
|
|
1697
|
+
}));
|
|
1698
|
+
if (isFailure(sessionResult)) {
|
|
1699
|
+
logError(sessionResult.error, `[SubscriptionController] [purchaseContract] Failed to create checkout session for contract: ${item.id}`);
|
|
1700
|
+
await voidTransfers();
|
|
1701
|
+
return failure({
|
|
1702
|
+
errorCode: 'server_error',
|
|
1703
|
+
errorMessage: 'Failed to create checkout session for contract.',
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
const session = sessionResult.value;
|
|
1707
|
+
checkoutSession.stripeCheckoutSessionId = session.id;
|
|
1708
|
+
if (session.invoice) {
|
|
1709
|
+
checkoutSession.invoice = {
|
|
1710
|
+
currency: session.invoice.currency,
|
|
1711
|
+
paid: session.invoice.paid,
|
|
1712
|
+
description: session.invoice.description,
|
|
1713
|
+
status: session.invoice.status,
|
|
1714
|
+
stripeInvoiceId: session.invoice.id,
|
|
1715
|
+
stripeHostedInvoiceUrl: session.invoice.hosted_invoice_url,
|
|
1716
|
+
stripeInvoicePdfUrl: session.invoice.invoice_pdf,
|
|
1717
|
+
tax: session.invoice.tax,
|
|
1718
|
+
total: session.invoice.total,
|
|
1719
|
+
subtotal: session.invoice.subtotal,
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
checkoutSession.status = session.status;
|
|
1723
|
+
checkoutSession.paymentStatus = session.payment_status;
|
|
1724
|
+
checkoutSession.paid =
|
|
1725
|
+
session.payment_status === 'paid' ||
|
|
1726
|
+
session.payment_status === 'no_payment_required';
|
|
1727
|
+
checkoutSession.transactionId =
|
|
1728
|
+
transferResult.value.transactionId;
|
|
1729
|
+
checkoutSession.transferIds = transferResult.value.transferIds;
|
|
1730
|
+
checkoutSession.transfersPending = true;
|
|
1731
|
+
url = session.url;
|
|
1732
|
+
// Do not immediately fulfill since we need to wait for Stripe webhook
|
|
1733
|
+
immediateFulfillment = false;
|
|
1734
|
+
}
|
|
1735
|
+
await this._authStore.updateCheckoutSessionInfo(checkoutSession);
|
|
1736
|
+
if (immediateFulfillment) {
|
|
1737
|
+
const fulfillmentResult = await this.fulfillCheckoutSession({
|
|
1738
|
+
sessionId: checkoutSession.id,
|
|
1739
|
+
activation: 'now',
|
|
1740
|
+
userId: request.userId,
|
|
1741
|
+
});
|
|
1742
|
+
if (fulfillmentResult.success === false) {
|
|
1743
|
+
console.error(`[SubscriptionController] [purchaseContract] Failed to immediately fulfill checkout session for contract: ${item.id}:`, fulfillmentResult.errorMessage);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
return success({
|
|
1747
|
+
url,
|
|
1748
|
+
sessionId: sessionId,
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
catch (err) {
|
|
1752
|
+
console.error('[SubscriptionController] An error occurred while purchasing a contract:', err);
|
|
1753
|
+
return failure({
|
|
1754
|
+
errorCode: 'server_error',
|
|
1755
|
+
errorMessage: 'A server error occurred.',
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Cancels a contract and issues a refund if applicable.
|
|
1761
|
+
* @param request The request to cancel the contract.
|
|
1762
|
+
* @returns A promise that resolves to the result of the cancel contract operation.
|
|
1763
|
+
*/
|
|
1764
|
+
async cancelContract(request) {
|
|
1765
|
+
const context = await this._policies.constructAuthorizationContext({
|
|
1766
|
+
recordKeyOrRecordName: request.recordName,
|
|
1767
|
+
userId: request.userId,
|
|
1768
|
+
});
|
|
1769
|
+
if (context.success === false) {
|
|
1770
|
+
return failure(context);
|
|
1771
|
+
}
|
|
1772
|
+
const recordName = context.context.recordName;
|
|
1773
|
+
const item = await this._contractRecords.getItemByAddress(recordName, request.address);
|
|
1774
|
+
if (!item) {
|
|
1775
|
+
return failure({
|
|
1776
|
+
errorCode: 'not_found',
|
|
1777
|
+
errorMessage: 'The contract could not be found.',
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
const authorization = await this._policies.authorizeUserAndInstances(context.context, {
|
|
1781
|
+
userId: request.userId,
|
|
1782
|
+
instances: request.instances,
|
|
1783
|
+
resourceKind: 'contract',
|
|
1784
|
+
resourceId: item.address,
|
|
1785
|
+
markers: item.markers,
|
|
1786
|
+
action: 'cancel',
|
|
1787
|
+
});
|
|
1788
|
+
if (authorization.success === false) {
|
|
1789
|
+
return failure(authorization);
|
|
1790
|
+
}
|
|
1791
|
+
if (item.status === 'closed') {
|
|
1792
|
+
return success({
|
|
1793
|
+
refundedAmount: 0,
|
|
1794
|
+
refundCurrency: CurrencyCodes.usd,
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
const refundResult = await this._refundContract(request, item, context.context);
|
|
1798
|
+
if (isSuccess(refundResult)) {
|
|
1799
|
+
await this._contractRecords.markContractAsClosed(recordName, item.address);
|
|
1800
|
+
}
|
|
1801
|
+
return refundResult;
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* Issues a new invoice for a contract.
|
|
1805
|
+
* @param request The request to invoice the contract.
|
|
1806
|
+
*/
|
|
1807
|
+
async invoiceContract(request) {
|
|
1808
|
+
if (request.amount <= 0) {
|
|
1809
|
+
return failure({
|
|
1810
|
+
errorCode: 'invalid_request',
|
|
1811
|
+
errorMessage: 'The invoice amount must be greater than zero.',
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
const contract = await this._contractRecords.getItemById(request.contractId);
|
|
1815
|
+
if (!contract?.contract) {
|
|
1816
|
+
return failure({
|
|
1817
|
+
errorCode: 'not_found',
|
|
1818
|
+
errorMessage: 'The contract could not be found.',
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
if (!isSuperUserRole(request.userRole)) {
|
|
1822
|
+
if (contract.contract.holdingUserId !== request.userId) {
|
|
1823
|
+
return failure({
|
|
1824
|
+
errorCode: 'not_authorized',
|
|
1825
|
+
errorMessage: 'You are not authorized to invoice for the contract.',
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
if (contract.contract.status !== 'open') {
|
|
1830
|
+
return failure({
|
|
1831
|
+
errorCode: 'invalid_request',
|
|
1832
|
+
errorMessage: 'The contract is not open for invoicing.',
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
const balance = await this._financialController.getAccountBalance({
|
|
1836
|
+
contractId: contract.contract.id,
|
|
1837
|
+
ledger: LEDGERS.usd,
|
|
1838
|
+
});
|
|
1839
|
+
if (isFailure(balance)) {
|
|
1840
|
+
logError(balance.error, `[SubscriptionController] [invoiceContract] Failed to get account balance for contract: ${contract.contract.id}`);
|
|
1841
|
+
return balance;
|
|
1842
|
+
}
|
|
1843
|
+
if (balance.value.freeCreditBalance() < request.amount) {
|
|
1844
|
+
return failure({
|
|
1845
|
+
errorCode: 'insufficient_funds',
|
|
1846
|
+
errorMessage: 'The contract does not have sufficient funds to cover the invoice amount.',
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
const now = Date.now();
|
|
1850
|
+
const invoiceId = uuid();
|
|
1851
|
+
await this._contractRecords.createInvoice({
|
|
1852
|
+
id: invoiceId,
|
|
1853
|
+
contractId: contract.contract.id,
|
|
1854
|
+
amount: request.amount,
|
|
1855
|
+
status: 'open',
|
|
1856
|
+
payoutDestination: request.payoutDestination,
|
|
1857
|
+
note: request.note,
|
|
1858
|
+
openedAtMs: now,
|
|
1859
|
+
createdAtMs: now,
|
|
1860
|
+
updatedAtMs: now,
|
|
1861
|
+
});
|
|
1862
|
+
return success({
|
|
1863
|
+
invoiceId,
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
/**
|
|
1867
|
+
* Cancels an invoice for a contract.
|
|
1868
|
+
* @param request The request to cancel the invoice.
|
|
1869
|
+
*/
|
|
1870
|
+
async cancelInvoice(request) {
|
|
1871
|
+
const invoice = await this._contractRecords.getInvoiceById(request.invoiceId);
|
|
1872
|
+
if (!invoice?.invoice) {
|
|
1873
|
+
return failure({
|
|
1874
|
+
errorCode: 'not_found',
|
|
1875
|
+
errorMessage: 'The invoice could not be found.',
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
// TODO: Use the permissions system for viewing invoices
|
|
1879
|
+
if (!isSuperUserRole(request.userRole)) {
|
|
1880
|
+
if (invoice.contract.issuingUserId !== request.userId &&
|
|
1881
|
+
invoice.contract.holdingUserId !== request.userId) {
|
|
1882
|
+
return failure({
|
|
1883
|
+
errorCode: 'not_authorized',
|
|
1884
|
+
errorMessage: 'You are not authorized to perform this operation.',
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
if (invoice.invoice.status !== 'open') {
|
|
1889
|
+
return failure({
|
|
1890
|
+
errorCode: 'invalid_request',
|
|
1891
|
+
errorMessage: 'Only open invoices can be cancelled.',
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
await this._contractRecords.markOpenInvoiceAs(request.invoiceId, 'void');
|
|
1895
|
+
return success();
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* Lists all invoices for a contract.
|
|
1899
|
+
* @param request The request to list the invoices.
|
|
1900
|
+
*/
|
|
1901
|
+
async listContractInvoices(request) {
|
|
1902
|
+
const contract = await this._contractRecords.getItemById(request.contractId);
|
|
1903
|
+
if (!contract?.contract) {
|
|
1904
|
+
return failure({
|
|
1905
|
+
errorCode: 'not_found',
|
|
1906
|
+
errorMessage: 'The contract could not be found.',
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
// TODO: Use the permissions system for viewing invoices
|
|
1910
|
+
if (!isSuperUserRole(request.userRole)) {
|
|
1911
|
+
if (contract.contract.issuingUserId !== request.userId &&
|
|
1912
|
+
contract.contract.holdingUserId !== request.userId) {
|
|
1913
|
+
return failure({
|
|
1914
|
+
errorCode: 'not_authorized',
|
|
1915
|
+
errorMessage: 'You are not authorized to view invoices for the contract.',
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
const invoices = await this._contractRecords.listInvoicesForContract(request.contractId);
|
|
1920
|
+
return success(invoices);
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* Pays an invoice for a contract.
|
|
1924
|
+
* @param request The request to pay the invoice.
|
|
1925
|
+
*/
|
|
1926
|
+
async payContractInvoice(request) {
|
|
1927
|
+
if (!this._financialController) {
|
|
1928
|
+
return failure({
|
|
1929
|
+
errorCode: 'not_supported',
|
|
1930
|
+
errorMessage: 'This operation is not supported.',
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
const invoice = await this._contractRecords.getInvoiceById(request.invoiceId);
|
|
1934
|
+
if (!invoice) {
|
|
1935
|
+
return failure({
|
|
1936
|
+
errorCode: 'not_found',
|
|
1937
|
+
errorMessage: 'The invoice could not be found.',
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
if (!isSuperUserRole(request.userRole)) {
|
|
1941
|
+
if (invoice.contract.issuingUserId !== request.userId) {
|
|
1942
|
+
return failure({
|
|
1943
|
+
errorCode: 'not_authorized',
|
|
1944
|
+
errorMessage: 'You are not authorized to pay invoices for the contract.',
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
// transfer from contract account to payout destination
|
|
1949
|
+
if (invoice.invoice.status !== 'open') {
|
|
1950
|
+
return failure({
|
|
1951
|
+
errorCode: 'invalid_request',
|
|
1952
|
+
errorMessage: 'The invoice is not open and cannot be paid.',
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
const holdingUser = await this._authStore.findUser(invoice.contract.holdingUserId);
|
|
1956
|
+
if (!holdingUser) {
|
|
1957
|
+
console.error(`[SubscriptionController] [payInvoice] Failed to find holding user for contract: ${invoice.contract.id}`);
|
|
1958
|
+
return failure({
|
|
1959
|
+
errorCode: 'server_error',
|
|
1960
|
+
errorMessage: 'The server encountered an error.',
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
const [userAccount, contractAccount] = await Promise.all([
|
|
1964
|
+
this._financialController.getOrCreateFinancialAccount({
|
|
1965
|
+
userId: holdingUser.id,
|
|
1966
|
+
ledger: LEDGERS.usd,
|
|
1967
|
+
}),
|
|
1968
|
+
this._financialController.getFinancialAccount({
|
|
1969
|
+
contractId: invoice.contract.id,
|
|
1970
|
+
ledger: LEDGERS.usd,
|
|
1971
|
+
}),
|
|
1972
|
+
]);
|
|
1973
|
+
if (isFailure(userAccount)) {
|
|
1974
|
+
logError(userAccount.error, `[SubscriptionController] [payInvoice] Failed to get or create financial account for user: ${holdingUser.id}`);
|
|
1975
|
+
return userAccount;
|
|
1976
|
+
}
|
|
1977
|
+
else if (isFailure(contractAccount)) {
|
|
1978
|
+
logError(contractAccount.error, `[SubscriptionController] [payInvoice] Failed to get financial account for contract: ${invoice.contract.id}`);
|
|
1979
|
+
return contractAccount;
|
|
1980
|
+
}
|
|
1981
|
+
if (invoice.invoice.payoutDestination === 'stripe') {
|
|
1982
|
+
if (!holdingUser.stripeAccountId) {
|
|
1983
|
+
return failure({
|
|
1984
|
+
errorCode: 'invalid_request',
|
|
1985
|
+
errorMessage: 'The user to be paid does not have a Stripe account connected.',
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
if (holdingUser.stripeAccountStatus !== 'active') {
|
|
1989
|
+
return failure({
|
|
1990
|
+
errorCode: 'invalid_request',
|
|
1991
|
+
errorMessage: 'The user to be paid does not have an active Stripe account.',
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
const transactionResult = await this._financialController.internalTransaction({
|
|
1996
|
+
transfers: [
|
|
1997
|
+
{
|
|
1998
|
+
amount: invoice.invoice.amount,
|
|
1999
|
+
creditAccountId: userAccount.value.account.id,
|
|
2000
|
+
debitAccountId: contractAccount.value.account.id,
|
|
2001
|
+
code: TransferCodes.contract_payment,
|
|
2002
|
+
currency: CurrencyCodes.usd,
|
|
2003
|
+
},
|
|
2004
|
+
],
|
|
2005
|
+
});
|
|
2006
|
+
if (isFailure(transactionResult)) {
|
|
2007
|
+
if (transactionResult.error.errorCode === 'debits_exceed_credits') {
|
|
2008
|
+
return failure({
|
|
2009
|
+
errorCode: 'insufficient_funds',
|
|
2010
|
+
errorMessage: 'The contract does not have sufficient funds to pay the invoice.',
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
logError(transactionResult.error, `[SubscriptionController] [payInvoice] Failed to pay invoice: ${invoice.invoice.id}`);
|
|
2014
|
+
return failure({
|
|
2015
|
+
errorCode: 'server_error',
|
|
2016
|
+
errorMessage: 'The server encountered an error.',
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
await this._contractRecords.markOpenInvoiceAs(request.invoiceId, 'paid');
|
|
2020
|
+
if (invoice.invoice.payoutDestination === 'stripe') {
|
|
2021
|
+
const result = await this.payoutAccount({
|
|
2022
|
+
userId: null,
|
|
2023
|
+
userRole: 'system',
|
|
2024
|
+
payoutUserId: holdingUser.id,
|
|
2025
|
+
payoutDestination: 'stripe',
|
|
2026
|
+
payoutAmount: invoice.invoice.amount,
|
|
2027
|
+
contractId: invoice.contract.id,
|
|
2028
|
+
invoiceId: invoice.invoice.id,
|
|
2029
|
+
});
|
|
2030
|
+
if (isFailure(result)) {
|
|
2031
|
+
return result;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
else if (invoice.invoice.payoutDestination === 'account') {
|
|
2035
|
+
// Nothing to do since we already transferred to the user's account
|
|
2036
|
+
}
|
|
2037
|
+
else {
|
|
2038
|
+
return failure({
|
|
2039
|
+
errorCode: 'invalid_request',
|
|
2040
|
+
errorMessage: 'The invoice has an invalid payout destination.',
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
return success();
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Attempts to payout an account.
|
|
2047
|
+
* @param request The request.
|
|
2048
|
+
*/
|
|
2049
|
+
async payoutAccount(request) {
|
|
2050
|
+
if (!this._financialController) {
|
|
2051
|
+
return failure({
|
|
2052
|
+
errorCode: 'not_supported',
|
|
2053
|
+
errorMessage: 'This operation is not supported.',
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
if (typeof request.payoutAmount === 'number' &&
|
|
2057
|
+
request.payoutAmount <= 0) {
|
|
2058
|
+
return failure({
|
|
2059
|
+
errorCode: 'invalid_request',
|
|
2060
|
+
errorMessage: 'The payout amount must be greater than zero.',
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
if (!isSuperUserRole(request.userRole)) {
|
|
2064
|
+
if (request.payoutUserId &&
|
|
2065
|
+
request.userId !== request.payoutUserId) {
|
|
2066
|
+
return failure({
|
|
2067
|
+
errorCode: 'not_authorized',
|
|
2068
|
+
errorMessage: 'You are not authorized to payout this account.',
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
2071
|
+
else if (request.payoutStudioId) {
|
|
2072
|
+
// TODO: Check studio permissions
|
|
2073
|
+
return failure({
|
|
2074
|
+
errorCode: 'not_supported',
|
|
2075
|
+
errorMessage: 'Studio payouts are not supported yet.',
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
if (request.payoutDestination === 'stripe') {
|
|
2080
|
+
return this._payoutToStripe(request);
|
|
2081
|
+
}
|
|
2082
|
+
else if (request.payoutDestination === 'cash') {
|
|
2083
|
+
if (!isSuperUserRole(request.userRole)) {
|
|
2084
|
+
return failure({
|
|
2085
|
+
errorCode: 'not_authorized',
|
|
2086
|
+
errorMessage: 'You are not authorized to payout to cash.',
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
return this._payoutToCash(request);
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
async _payoutToStripe(request) {
|
|
2093
|
+
if (request.payoutStudioId && request.payoutUserId) {
|
|
2094
|
+
return failure({
|
|
2095
|
+
errorCode: 'invalid_request',
|
|
2096
|
+
errorMessage: 'Cannot payout to both a user and a studio at the same time.',
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
let destinationStripeAccount;
|
|
2100
|
+
if (request.payoutUserId) {
|
|
2101
|
+
const user = await this._authStore.findUser(request.payoutUserId);
|
|
2102
|
+
if (!user) {
|
|
2103
|
+
return failure({
|
|
2104
|
+
errorCode: 'not_found',
|
|
2105
|
+
errorMessage: 'The user could not be found.',
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
if (!user.stripeAccountId) {
|
|
2109
|
+
return failure({
|
|
2110
|
+
errorCode: 'invalid_request',
|
|
2111
|
+
errorMessage: 'The user does not have a Stripe account connected.',
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
if (user.stripeAccountStatus !== 'active') {
|
|
2115
|
+
return failure({
|
|
2116
|
+
errorCode: 'invalid_request',
|
|
2117
|
+
errorMessage: 'The user does not have an active Stripe account.',
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
destinationStripeAccount = user.stripeAccountId;
|
|
2121
|
+
}
|
|
2122
|
+
else if (request.payoutStudioId) {
|
|
2123
|
+
const studio = await this._recordsStore.getStudioById(request.payoutStudioId);
|
|
2124
|
+
if (!studio) {
|
|
2125
|
+
return failure({
|
|
2126
|
+
errorCode: 'not_found',
|
|
2127
|
+
errorMessage: 'The studio could not be found.',
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
if (!studio.stripeAccountId) {
|
|
2131
|
+
return failure({
|
|
2132
|
+
errorCode: 'invalid_request',
|
|
2133
|
+
errorMessage: 'The studio does not have a Stripe account connected.',
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
if (studio.stripeAccountStatus !== 'active') {
|
|
2137
|
+
return failure({
|
|
2138
|
+
errorCode: 'invalid_request',
|
|
2139
|
+
errorMessage: 'The studio does not have an active Stripe account.',
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
destinationStripeAccount = studio.stripeAccountId;
|
|
2143
|
+
}
|
|
2144
|
+
const [debitAccount] = await Promise.all([
|
|
2145
|
+
this._financialController.getOrCreateFinancialAccount({
|
|
2146
|
+
userId: request.payoutUserId,
|
|
2147
|
+
studioId: request.payoutStudioId,
|
|
2148
|
+
ledger: LEDGERS.usd,
|
|
2149
|
+
}),
|
|
2150
|
+
]);
|
|
2151
|
+
if (isFailure(debitAccount)) {
|
|
2152
|
+
logError(debitAccount.error, `[SubscriptionController] [payoutAccount] Failed to get or create financial account:`);
|
|
2153
|
+
return debitAccount;
|
|
2154
|
+
}
|
|
2155
|
+
let amount;
|
|
2156
|
+
let balancingDebit = false;
|
|
2157
|
+
if (request.payoutAmount) {
|
|
2158
|
+
amount = request.payoutAmount;
|
|
2159
|
+
}
|
|
2160
|
+
else {
|
|
2161
|
+
amount = AMOUNT_MAX;
|
|
2162
|
+
balancingDebit = true;
|
|
2163
|
+
}
|
|
2164
|
+
const transactionResult = await this._financialController.internalTransaction({
|
|
2165
|
+
transfers: [
|
|
2166
|
+
{
|
|
2167
|
+
amount: amount,
|
|
2168
|
+
creditAccountId: ACCOUNT_IDS.assets_stripe,
|
|
2169
|
+
debitAccountId: debitAccount.value.account.id,
|
|
2170
|
+
code: TransferCodes.user_payout,
|
|
2171
|
+
currency: CurrencyCodes.usd,
|
|
2172
|
+
pending: true,
|
|
2173
|
+
timeoutSeconds: STRIPE_PAYOUT_TIMEOUT_SECONDS,
|
|
2174
|
+
balancingDebit: balancingDebit,
|
|
2175
|
+
},
|
|
2176
|
+
],
|
|
2177
|
+
});
|
|
2178
|
+
if (isFailure(transactionResult)) {
|
|
2179
|
+
if (transactionResult.error.errorCode === 'debits_exceed_credits') {
|
|
2180
|
+
return failure({
|
|
2181
|
+
errorCode: 'insufficient_funds',
|
|
2182
|
+
errorMessage: 'The account does not have sufficient funds to complete the payout.',
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
else if (transactionResult.error.errorCode === 'credits_exceed_debits') {
|
|
2186
|
+
logError(transactionResult.error, `[SubscriptionController] [payoutAccount] Unable to payout to Stripe due to insufficient funds:`);
|
|
2187
|
+
return failure({
|
|
2188
|
+
errorCode: 'server_error',
|
|
2189
|
+
errorMessage: 'The server encountered an error.',
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
2192
|
+
logError(transactionResult.error, `[SubscriptionController] [payoutAccount] Failed to create payout transaction:`);
|
|
2193
|
+
return failure({
|
|
2194
|
+
errorCode: 'server_error',
|
|
2195
|
+
errorMessage: 'The server encountered an error.',
|
|
2196
|
+
});
|
|
2197
|
+
}
|
|
2198
|
+
const voidTransfers = async (payoutId) => {
|
|
2199
|
+
console.log(`[SubscriptionController] [payoutAccount] Voiding pending transfers for failed payout.`);
|
|
2200
|
+
const voidResult = await this._financialController.completePendingTransfers({
|
|
2201
|
+
transfers: transactionResult.value.transferIds,
|
|
2202
|
+
transactionId: transactionResult.value.transactionId,
|
|
2203
|
+
flags: TransferFlags.void_pending_transfer,
|
|
2204
|
+
});
|
|
2205
|
+
if (isFailure(voidResult)) {
|
|
2206
|
+
logError(voidResult.error, `[SubscriptionController] [payoutAccount] Failed to void pending transfers for payout:`);
|
|
2207
|
+
}
|
|
2208
|
+
else if (payoutId) {
|
|
2209
|
+
await this._financialStore.markPayoutAsVoided(payoutId, voidResult.value.transferIds[0], Date.now());
|
|
2210
|
+
}
|
|
2211
|
+
};
|
|
2212
|
+
const transfer = await this._financialController.getTransfer(transactionResult.value.transferIds[0]);
|
|
2213
|
+
if (isFailure(transfer)) {
|
|
2214
|
+
logError(transfer.error, `[SubscriptionController] [payoutAccount] Failed to get transfer for payout:`);
|
|
2215
|
+
await voidTransfers();
|
|
2216
|
+
return failure({
|
|
2217
|
+
errorCode: 'server_error',
|
|
2218
|
+
errorMessage: 'The server encountered an error.',
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
2221
|
+
else if (transfer.value.amount <= 0) {
|
|
2222
|
+
return failure({
|
|
2223
|
+
errorCode: 'invalid_request',
|
|
2224
|
+
errorMessage: 'There are no funds to transfer.',
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
const payoutId = uuid();
|
|
2228
|
+
try {
|
|
2229
|
+
await this._financialStore.createExternalPayout({
|
|
2230
|
+
id: payoutId,
|
|
2231
|
+
amount: Number(transfer.value.amount),
|
|
2232
|
+
externalDestination: 'stripe',
|
|
2233
|
+
destinationStripeAccountId: destinationStripeAccount,
|
|
2234
|
+
initatedAtMs: Date.now(),
|
|
2235
|
+
transferId: transfer.value.id.toString(),
|
|
2236
|
+
transactionId: transactionResult.value.transactionId,
|
|
2237
|
+
invoiceId: request.invoiceId,
|
|
2238
|
+
userId: request.payoutUserId,
|
|
2239
|
+
studioId: request.payoutStudioId,
|
|
2240
|
+
});
|
|
2241
|
+
let sourceTransaction = undefined;
|
|
2242
|
+
if (request.contractId) {
|
|
2243
|
+
const contract = await this._contractRecords.getItemById(request.contractId);
|
|
2244
|
+
if (contract?.contract.stripePaymentIntentId) {
|
|
2245
|
+
const paymentIntent = await this._stripe.getPaymentIntentById(contract.contract.stripePaymentIntentId);
|
|
2246
|
+
sourceTransaction = paymentIntent.latest_charge;
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
const result = await this._stripe.createTransfer({
|
|
2250
|
+
currency: 'usd',
|
|
2251
|
+
destination: destinationStripeAccount,
|
|
2252
|
+
amount: Number(transfer.value.amount),
|
|
2253
|
+
description: 'Account Payout',
|
|
2254
|
+
transferGroup: request.contractId,
|
|
2255
|
+
sourceTransaction,
|
|
2256
|
+
metadata: {
|
|
2257
|
+
transactionId: transactionResult.value.transactionId,
|
|
2258
|
+
payoutId: payoutId,
|
|
2259
|
+
payoutUserId: request.payoutUserId,
|
|
2260
|
+
payoutStudioId: request.payoutStudioId,
|
|
2261
|
+
},
|
|
2262
|
+
});
|
|
2263
|
+
// TODO: update external payout with stripe transfer id
|
|
2264
|
+
await this._financialStore.updateExternalPayout({
|
|
2265
|
+
id: payoutId,
|
|
2266
|
+
stripeTransferId: result.id,
|
|
2267
|
+
});
|
|
2268
|
+
const postResult = await this._financialController.completePendingTransfers({
|
|
2269
|
+
transfers: transactionResult.value.transferIds,
|
|
2270
|
+
transactionId: transactionResult.value.transactionId,
|
|
2271
|
+
});
|
|
2272
|
+
if (isFailure(postResult)) {
|
|
2273
|
+
logError(postResult.error, `[SubscriptionController] [payoutAccount] Failed to complete pending transfers for payout:`);
|
|
2274
|
+
await voidTransfers(payoutId);
|
|
2275
|
+
return failure({
|
|
2276
|
+
errorCode: 'server_error',
|
|
2277
|
+
errorMessage: 'The server encountered an error.',
|
|
2278
|
+
});
|
|
2279
|
+
}
|
|
2280
|
+
await this._financialStore.markPayoutAsPosted(payoutId, postResult.value.transferIds[0], Date.now());
|
|
2281
|
+
return success({
|
|
2282
|
+
payoutId,
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
catch (err) {
|
|
2286
|
+
console.error(`[SubscriptionController] [payoutAccount] Failed to create Stripe transfer for payout:`, err);
|
|
2287
|
+
await voidTransfers(payoutId);
|
|
2288
|
+
return failure({
|
|
2289
|
+
errorCode: 'server_error',
|
|
2290
|
+
errorMessage: 'The server encountered an error.',
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
async _payoutToCash(request) {
|
|
2295
|
+
if (request.payoutStudioId && request.payoutUserId) {
|
|
2296
|
+
return failure({
|
|
2297
|
+
errorCode: 'invalid_request',
|
|
2298
|
+
errorMessage: 'Cannot payout to both a user and a studio at the same time.',
|
|
2299
|
+
});
|
|
2300
|
+
}
|
|
2301
|
+
const [debitAccount] = await Promise.all([
|
|
2302
|
+
this._financialController.getOrCreateFinancialAccount({
|
|
2303
|
+
userId: request.payoutUserId,
|
|
2304
|
+
studioId: request.payoutStudioId,
|
|
2305
|
+
ledger: LEDGERS.usd,
|
|
2306
|
+
}),
|
|
2307
|
+
]);
|
|
2308
|
+
if (isFailure(debitAccount)) {
|
|
2309
|
+
logError(debitAccount.error, `[SubscriptionController] [payoutAccount] Failed to get or create financial account:`);
|
|
2310
|
+
return debitAccount;
|
|
2311
|
+
}
|
|
2312
|
+
let amount;
|
|
2313
|
+
let balancingDebit = false;
|
|
2314
|
+
if (request.payoutAmount) {
|
|
2315
|
+
amount = request.payoutAmount;
|
|
2316
|
+
}
|
|
2317
|
+
else {
|
|
2318
|
+
amount = AMOUNT_MAX;
|
|
2319
|
+
balancingDebit = true;
|
|
2320
|
+
}
|
|
2321
|
+
const transactionResult = await this._financialController.internalTransaction({
|
|
2322
|
+
transfers: [
|
|
2323
|
+
{
|
|
2324
|
+
amount: amount,
|
|
2325
|
+
creditAccountId: ACCOUNT_IDS.assets_cash,
|
|
2326
|
+
debitAccountId: debitAccount.value.account.id,
|
|
2327
|
+
code: TransferCodes.user_payout,
|
|
2328
|
+
currency: CurrencyCodes.usd,
|
|
2329
|
+
balancingDebit: balancingDebit,
|
|
2330
|
+
},
|
|
2331
|
+
],
|
|
2332
|
+
});
|
|
2333
|
+
if (isFailure(transactionResult)) {
|
|
2334
|
+
if (transactionResult.error.errorCode === 'debits_exceed_credits') {
|
|
2335
|
+
return failure({
|
|
2336
|
+
errorCode: 'insufficient_funds',
|
|
2337
|
+
errorMessage: 'The account does not have sufficient funds to complete the payout.',
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
else if (transactionResult.error.errorCode === 'credits_exceed_debits') {
|
|
2341
|
+
logError(transactionResult.error, `[SubscriptionController] [payoutAccount] Unable to payout to Stripe due to insufficient funds:`);
|
|
2342
|
+
return failure({
|
|
2343
|
+
errorCode: 'server_error',
|
|
2344
|
+
errorMessage: 'The server encountered an error.',
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
logError(transactionResult.error, `[SubscriptionController] [payoutAccount] Failed to create payout transaction:`);
|
|
2348
|
+
return failure({
|
|
2349
|
+
errorCode: 'server_error',
|
|
2350
|
+
errorMessage: 'The server encountered an error.',
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
2353
|
+
const transfer = await this._financialController.getTransfer(transactionResult.value.transferIds[0]);
|
|
2354
|
+
if (isFailure(transfer)) {
|
|
2355
|
+
logError(transfer.error, `[SubscriptionController] [payoutAccount] Failed to get transfer for payout:`);
|
|
2356
|
+
return failure({
|
|
2357
|
+
errorCode: 'server_error',
|
|
2358
|
+
errorMessage: 'The server encountered an error.',
|
|
2359
|
+
});
|
|
2360
|
+
}
|
|
2361
|
+
const payoutId = uuid();
|
|
2362
|
+
await this._financialStore.createExternalPayout({
|
|
2363
|
+
id: payoutId,
|
|
2364
|
+
amount: Number(transfer.value.amount),
|
|
2365
|
+
externalDestination: 'cash',
|
|
2366
|
+
initatedAtMs: Date.now(),
|
|
2367
|
+
transferId: transfer.value.id.toString(),
|
|
2368
|
+
transactionId: transactionResult.value.transactionId,
|
|
2369
|
+
invoiceId: request.invoiceId,
|
|
2370
|
+
userId: request.payoutUserId,
|
|
2371
|
+
studioId: request.payoutStudioId,
|
|
2372
|
+
postedAtMs: Date.now(),
|
|
2373
|
+
postedTransferId: transfer.value.id.toString(),
|
|
2374
|
+
});
|
|
2375
|
+
return success({
|
|
2376
|
+
payoutId,
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2379
|
+
/**
|
|
2380
|
+
* Initiates a refund for a canceled contract.
|
|
2381
|
+
* @param request The request to cancel the contract.
|
|
2382
|
+
* @param item The contract record to refund.
|
|
2383
|
+
* @param context The authorization context for the operation.
|
|
2384
|
+
* @returns A promise that resolves to the result of the refund operation.
|
|
2385
|
+
*/
|
|
2386
|
+
async _refundContract(request, item, context) {
|
|
2387
|
+
const contractAccount = await this._financialController.getFinancialAccount({
|
|
2388
|
+
contractId: item.id,
|
|
2389
|
+
ledger: LEDGERS.usd,
|
|
2390
|
+
});
|
|
2391
|
+
if (isFailure(contractAccount)) {
|
|
2392
|
+
if (contractAccount.error.errorCode === 'not_found') {
|
|
2393
|
+
return success({
|
|
2394
|
+
refundedAmount: 0,
|
|
2395
|
+
refundCurrency: CurrencyCodes.usd,
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
logError(contractAccount.error, `[SubscriptionController] [cancelContract] Failed to get USD financial account for contract:`);
|
|
2399
|
+
return contractAccount;
|
|
2400
|
+
}
|
|
2401
|
+
let refundAccount;
|
|
2402
|
+
if (request.refundAccountId) {
|
|
2403
|
+
const account = await this._financialController.getAccount(request.refundAccountId);
|
|
2404
|
+
if (isFailure(account)) {
|
|
2405
|
+
logError(account.error, `[SubscriptionController] [cancelContract] Failed to get refund account:`);
|
|
2406
|
+
return account;
|
|
2407
|
+
}
|
|
2408
|
+
refundAccount = account.value;
|
|
2409
|
+
}
|
|
2410
|
+
if (!refundAccount) {
|
|
2411
|
+
const account = await this._financialController.getOrCreateFinancialAccount({
|
|
2412
|
+
userId: context.recordOwnerId,
|
|
2413
|
+
studioId: context.recordStudioId,
|
|
2414
|
+
ledger: contractAccount.value.account.ledger,
|
|
2415
|
+
});
|
|
2416
|
+
if (isFailure(account)) {
|
|
2417
|
+
logError(account.error, `[SubscriptionController] [cancelContract] Failed to get or create refund account:`);
|
|
2418
|
+
return account;
|
|
2419
|
+
}
|
|
2420
|
+
refundAccount = account.value.account;
|
|
2421
|
+
}
|
|
2422
|
+
console.log(`[SubscriptionController] [cancelContract contractId: ${item.id} contractAccountId: ${contractAccount.value.account.id} refundAccountId: ${refundAccount.id}] Attempting to cancel contract.`);
|
|
2423
|
+
const refundId = this._financialController.generateId();
|
|
2424
|
+
const cancelId = this._financialController.generateId();
|
|
2425
|
+
const transferResult = await this._financialController.internalTransaction({
|
|
2426
|
+
transfers: [
|
|
2427
|
+
{
|
|
2428
|
+
transferId: refundId,
|
|
2429
|
+
amount: AMOUNT_MAX,
|
|
2430
|
+
debitAccountId: contractAccount.value.account.id,
|
|
2431
|
+
creditAccountId: refundAccount.id,
|
|
2432
|
+
code: TransferCodes.contract_refund,
|
|
2433
|
+
currency: CurrencyCodes.usd,
|
|
2434
|
+
balancingDebit: true,
|
|
2435
|
+
},
|
|
2436
|
+
{
|
|
2437
|
+
transferId: cancelId,
|
|
2438
|
+
amount: 0,
|
|
2439
|
+
debitAccountId: contractAccount.value.account.id,
|
|
2440
|
+
creditAccountId: refundAccount.id,
|
|
2441
|
+
code: TransferCodes.account_closing,
|
|
2442
|
+
currency: CurrencyCodes.usd,
|
|
2443
|
+
closingDebit: true,
|
|
2444
|
+
},
|
|
2445
|
+
],
|
|
2446
|
+
});
|
|
2447
|
+
if (isFailure(transferResult)) {
|
|
2448
|
+
logError(transferResult.error, `[SubscriptionController] [cancelContract] Failed to refund contract:`);
|
|
2449
|
+
return failure({
|
|
2450
|
+
errorCode: 'server_error',
|
|
2451
|
+
errorMessage: 'Failed to refund the contract.',
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
const transfer = await this._financialController.getTransfer(refundId);
|
|
2455
|
+
if (isFailure(transfer)) {
|
|
2456
|
+
logError(transfer.error, `[SubscriptionController] [cancelContract] Failed to get transfer for contract refund:`);
|
|
2457
|
+
return failure({
|
|
2458
|
+
errorCode: 'server_error',
|
|
2459
|
+
errorMessage: 'The server encountered an error.',
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
return success({
|
|
2463
|
+
refundedAmount: Number(transfer.value.amount),
|
|
2464
|
+
refundCurrency: CURRENCIES.get(transfer.value.ledger),
|
|
2465
|
+
});
|
|
2466
|
+
}
|
|
2467
|
+
/**
|
|
2468
|
+
* Completes a checkout session. Grants the user access to the purchased items or completes a contract purchase.
|
|
2469
|
+
* @param request The request for the checkout session fulfillment.
|
|
2470
|
+
* @returns A promise that resolves to the result of the checkout session fulfillment.
|
|
2471
|
+
*/
|
|
2472
|
+
async fulfillCheckoutSession(request) {
|
|
2473
|
+
try {
|
|
2474
|
+
const session = await this._authStore.getCheckoutSessionById(request.sessionId);
|
|
2475
|
+
if (!session) {
|
|
2476
|
+
return {
|
|
2477
|
+
success: false,
|
|
2478
|
+
errorCode: 'not_found',
|
|
2479
|
+
errorMessage: 'The checkout session does not exist.',
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
if (!!session.userId && session.userId !== request.userId) {
|
|
2483
|
+
return {
|
|
2484
|
+
success: false,
|
|
2485
|
+
errorCode: 'not_authorized',
|
|
2486
|
+
errorMessage: 'You are not authorized to accept fulfillment of this checkout session.',
|
|
2487
|
+
};
|
|
2488
|
+
}
|
|
2489
|
+
if (session.stripeStatus === 'expired') {
|
|
2490
|
+
return {
|
|
2491
|
+
success: false,
|
|
2492
|
+
errorCode: 'invalid_request',
|
|
2493
|
+
errorMessage: 'The checkout session has expired.',
|
|
2494
|
+
};
|
|
2495
|
+
}
|
|
2496
|
+
else if (session.stripeStatus === 'open') {
|
|
2497
|
+
return {
|
|
2498
|
+
success: false,
|
|
2499
|
+
errorCode: 'invalid_request',
|
|
2500
|
+
errorMessage: 'The checkout session has not been completed.',
|
|
2501
|
+
};
|
|
2502
|
+
}
|
|
2503
|
+
else if (session.stripePaymentStatus === 'unpaid') {
|
|
2504
|
+
return {
|
|
2505
|
+
success: false,
|
|
2506
|
+
errorCode: 'invalid_request',
|
|
2507
|
+
errorMessage: 'The checkout session has not been paid for.',
|
|
2508
|
+
};
|
|
2509
|
+
}
|
|
2510
|
+
else if (!session.paid) {
|
|
2511
|
+
return {
|
|
2512
|
+
success: false,
|
|
2513
|
+
errorCode: 'invalid_request',
|
|
2514
|
+
errorMessage: 'The checkout session has not been paid for.',
|
|
2515
|
+
};
|
|
2516
|
+
}
|
|
2517
|
+
else if (session.fulfilledAtMs > 0) {
|
|
2518
|
+
return {
|
|
2519
|
+
success: true,
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
console.log(`[SubscriptionController] [fulfillCheckoutSession sessionId: ${session.id} userId: ${session.userId}] Fulfilling checkout session.`);
|
|
2523
|
+
if (session.transferIds && session.transfersPending) {
|
|
2524
|
+
console.log(`[SubscriptionController] [fulfillCheckoutSession sessionId: ${session.id} userId: ${session.userId} transactionId: ${session.transactionId}] Posting pending transfers.`);
|
|
2525
|
+
const transferResult = await this._financialController.completePendingTransfers({
|
|
2526
|
+
transfers: session.transferIds,
|
|
2527
|
+
transactionId: session.transactionId,
|
|
2528
|
+
});
|
|
2529
|
+
if (isFailure(transferResult)) {
|
|
2530
|
+
if (transferResult.error.errorCode !==
|
|
2531
|
+
'transfer_already_completed') {
|
|
2532
|
+
logError(transferResult.error, `[SubscriptionController] [fulfillCheckoutSession sessionId: ${session.id} userId: ${session.userId}] Failed to complete pending transfers for checkout session:`);
|
|
2533
|
+
return {
|
|
2534
|
+
success: false,
|
|
2535
|
+
errorCode: 'server_error',
|
|
2536
|
+
errorMessage: 'A server error occurred.',
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
if (request.activation === 'now') {
|
|
2542
|
+
if (!request.userId) {
|
|
2543
|
+
return {
|
|
2544
|
+
success: false,
|
|
2545
|
+
errorCode: 'invalid_request',
|
|
2546
|
+
errorMessage: 'Guests cannot accept immediate fulfillment of a checkout session.',
|
|
2547
|
+
};
|
|
2548
|
+
}
|
|
2549
|
+
console.log(`[SubscriptionController] [fulfillCheckoutSession sessionId: ${session.id} userId: ${session.userId}] Activating checkout session.`);
|
|
2550
|
+
// grant user access to the items
|
|
2551
|
+
for (let item of session.items) {
|
|
2552
|
+
if (item.type === 'role') {
|
|
2553
|
+
const result = await this._policyStore.assignSubjectRole(item.recordName, session.userId, 'user', {
|
|
2554
|
+
role: item.role,
|
|
2555
|
+
expireTimeMs: item.roleGrantTimeMs
|
|
2556
|
+
? Date.now() + item.roleGrantTimeMs
|
|
2557
|
+
: null,
|
|
2558
|
+
});
|
|
2559
|
+
if (result.success === false) {
|
|
2560
|
+
console.error(`[SubscriptionController] [fulfillCheckoutSession sessionId: ${session.id} userId: ${session.userId}] Unable to grant role to user:`, result);
|
|
2561
|
+
return {
|
|
2562
|
+
success: false,
|
|
2563
|
+
errorCode: 'server_error',
|
|
2564
|
+
errorMessage: 'A server error occurred.',
|
|
2565
|
+
};
|
|
2566
|
+
}
|
|
2567
|
+
await this._authStore.savePurchasedItem({
|
|
2568
|
+
id: uuid(),
|
|
2569
|
+
activatedTimeMs: Date.now(),
|
|
2570
|
+
recordName: item.recordName,
|
|
2571
|
+
purchasableItemAddress: item.purchasableItemAddress,
|
|
2572
|
+
roleName: item.role,
|
|
2573
|
+
roleGrantTimeMs: item.roleGrantTimeMs,
|
|
2574
|
+
userId: session.userId,
|
|
2575
|
+
activationKeyId: null,
|
|
2576
|
+
checkoutSessionId: session.id,
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
else if (item.type === 'contract') {
|
|
2580
|
+
// open contract
|
|
2581
|
+
await this._contractRecords.markPendingContractAsOpen(item.recordName, item.contractAddress);
|
|
2582
|
+
}
|
|
2583
|
+
else {
|
|
2584
|
+
// console.warn(
|
|
2585
|
+
// `[SubscriptionController] [fulfillCheckoutSession sessionId: ${session.id} userId: ${session.userId}] Unknown item type: ${item.type}`
|
|
2586
|
+
// );
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
await this._authStore.markCheckoutSessionFulfilled(session.id, Date.now());
|
|
2590
|
+
return {
|
|
2591
|
+
success: true,
|
|
2592
|
+
};
|
|
2593
|
+
}
|
|
2594
|
+
else {
|
|
2595
|
+
if (session.items.some((i) => i.type === 'contract')) {
|
|
2596
|
+
console.log(`[SubscriptionController] [fulfillCheckoutSession sessionId: ${session.id} userId: ${session.userId}] Cannot defer fulfillment of checkout session with contracts.`);
|
|
2597
|
+
return {
|
|
2598
|
+
success: false,
|
|
2599
|
+
errorCode: 'invalid_request',
|
|
2600
|
+
errorMessage: 'You cannot defer fulfillment of a checkout session with contracts.',
|
|
2601
|
+
};
|
|
2602
|
+
}
|
|
2603
|
+
console.log(`[SubscriptionController] [fulfillCheckoutSession sessionId: ${session.id} userId: ${session.userId}] Deferring activation for checkout session.`);
|
|
2604
|
+
const secret = fromByteArray(randomBytes(ACCESS_KEY_SECRET_BYTE_LENGTH));
|
|
2605
|
+
const keyId = fromByteArray(randomBytes(ACCESS_KEY_SECRET_BYTE_LENGTH));
|
|
2606
|
+
const hash = hashHighEntropyPasswordWithSalt(secret, keyId);
|
|
2607
|
+
await this._authStore.createActivationKey({
|
|
2608
|
+
id: keyId,
|
|
2609
|
+
secretHash: hash,
|
|
2610
|
+
});
|
|
2611
|
+
const key = formatV1ActivationKey(keyId, secret);
|
|
2612
|
+
// grant user access to the items
|
|
2613
|
+
for (let item of session.items) {
|
|
2614
|
+
if (item.type === 'role') {
|
|
2615
|
+
await this._authStore.savePurchasedItem({
|
|
2616
|
+
id: uuid(),
|
|
2617
|
+
activatedTimeMs: null,
|
|
2618
|
+
recordName: item.recordName,
|
|
2619
|
+
purchasableItemAddress: item.purchasableItemAddress,
|
|
2620
|
+
roleName: item.role,
|
|
2621
|
+
roleGrantTimeMs: item.roleGrantTimeMs,
|
|
2622
|
+
userId: null,
|
|
2623
|
+
activationKeyId: keyId,
|
|
2624
|
+
checkoutSessionId: session.id,
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
else {
|
|
2628
|
+
console.warn(`[SubscriptionController] [fulfillCheckoutSession sessionId: ${session.id} userId: ${session.userId}] Unknown item type: ${item.type}`);
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
await this._authStore.markCheckoutSessionFulfilled(session.id, Date.now());
|
|
2632
|
+
const config = await this._getConfig();
|
|
2633
|
+
return {
|
|
2634
|
+
success: true,
|
|
2635
|
+
activationKey: key,
|
|
2636
|
+
activationUrl: activationRoute(config.returnUrl, key),
|
|
2637
|
+
};
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
catch (err) {
|
|
2641
|
+
console.error('[SubscriptionController] An error occurred while fulfilling a checkout session:', err);
|
|
2642
|
+
return {
|
|
2643
|
+
success: false,
|
|
2644
|
+
errorCode: 'server_error',
|
|
2645
|
+
errorMessage: 'A server error occurred.',
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
async claimActivationKey(request) {
|
|
2650
|
+
try {
|
|
2651
|
+
if (!request.userId && request.target === 'self') {
|
|
2652
|
+
return {
|
|
2653
|
+
success: false,
|
|
2654
|
+
errorCode: 'not_logged_in',
|
|
2655
|
+
errorMessage: 'You need to be logged in to use target = self.',
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
const key = parseActivationKey(request.activationKey);
|
|
2659
|
+
if (!key) {
|
|
2660
|
+
return {
|
|
2661
|
+
success: false,
|
|
2662
|
+
errorCode: 'invalid_request',
|
|
2663
|
+
errorMessage: 'The activation key is invalid.',
|
|
2664
|
+
};
|
|
2665
|
+
}
|
|
2666
|
+
const [keyId, secret] = key;
|
|
2667
|
+
const activationKey = await this._authStore.getActivationKeyById(keyId);
|
|
2668
|
+
if (!activationKey) {
|
|
2669
|
+
return {
|
|
2670
|
+
success: false,
|
|
2671
|
+
errorCode: 'invalid_request',
|
|
2672
|
+
errorMessage: 'The activation key is invalid.',
|
|
2673
|
+
};
|
|
2674
|
+
}
|
|
2675
|
+
const hash = hashHighEntropyPasswordWithSalt(secret, keyId);
|
|
2676
|
+
if (activationKey.secretHash !== hash) {
|
|
2677
|
+
return {
|
|
2678
|
+
success: false,
|
|
2679
|
+
errorCode: 'invalid_request',
|
|
2680
|
+
errorMessage: 'The activation key is invalid.',
|
|
2681
|
+
};
|
|
2682
|
+
}
|
|
2683
|
+
let userId;
|
|
2684
|
+
let sessionKey;
|
|
2685
|
+
let connectionKey;
|
|
2686
|
+
let expireTimeMs;
|
|
2687
|
+
if (request.target === 'self') {
|
|
2688
|
+
userId = request.userId;
|
|
2689
|
+
}
|
|
2690
|
+
else if (request.target === 'guest') {
|
|
2691
|
+
console.log('[SubscriptionController] [claimActivationKey] Creating user for guest activation key.');
|
|
2692
|
+
const accountResult = await this._auth.createAccount({
|
|
2693
|
+
userRole: 'superUser',
|
|
2694
|
+
ipAddress: request.ipAddress,
|
|
2695
|
+
});
|
|
2696
|
+
if (accountResult.success === false) {
|
|
2697
|
+
console.error(`[SubscriptionController] [claimActivationKey keyId: ${keyId}] Unable to create user for guest activation key:`, accountResult);
|
|
2698
|
+
return {
|
|
2699
|
+
success: false,
|
|
2700
|
+
errorCode: 'server_error',
|
|
2701
|
+
errorMessage: 'A server error occurred.',
|
|
2702
|
+
};
|
|
2703
|
+
}
|
|
2704
|
+
userId = accountResult.userId;
|
|
2705
|
+
sessionKey = accountResult.sessionKey;
|
|
2706
|
+
connectionKey = accountResult.connectionKey;
|
|
2707
|
+
expireTimeMs = accountResult.expireTimeMs;
|
|
2708
|
+
}
|
|
2709
|
+
console.log(`[SubscriptionController] [claimActivationKey keyId: ${keyId} userId: ${request.userId}] Claiming activation key.`);
|
|
2710
|
+
if (!userId) {
|
|
2711
|
+
return {
|
|
2712
|
+
success: false,
|
|
2713
|
+
errorCode: 'invalid_request',
|
|
2714
|
+
errorMessage: 'The activation key is invalid.',
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
const items = await this._authStore.listPurchasedItemsByActivationKeyId(keyId);
|
|
2718
|
+
for (let item of items) {
|
|
2719
|
+
if (item.activatedTimeMs || item.userId) {
|
|
2720
|
+
continue;
|
|
2721
|
+
}
|
|
2722
|
+
const result = await this._policyStore.assignSubjectRole(item.recordName, userId, 'user', {
|
|
2723
|
+
role: item.roleName,
|
|
2724
|
+
expireTimeMs: item.roleGrantTimeMs
|
|
2725
|
+
? Date.now() + item.roleGrantTimeMs
|
|
2726
|
+
: null,
|
|
2727
|
+
});
|
|
2728
|
+
if (result.success === false) {
|
|
2729
|
+
console.error(`[SubscriptionController] [claimActivationKey keyId: ${keyId} userId: ${userId}] Unable to grant role to user:`, result);
|
|
2730
|
+
return {
|
|
2731
|
+
success: false,
|
|
2732
|
+
errorCode: 'server_error',
|
|
2733
|
+
errorMessage: 'A server error occurred.',
|
|
2734
|
+
};
|
|
2735
|
+
}
|
|
2736
|
+
await this._authStore.savePurchasedItem({
|
|
2737
|
+
...item,
|
|
2738
|
+
activatedTimeMs: Date.now(),
|
|
2739
|
+
userId,
|
|
2740
|
+
});
|
|
2741
|
+
}
|
|
2742
|
+
return {
|
|
2743
|
+
success: true,
|
|
2744
|
+
userId,
|
|
2745
|
+
sessionKey,
|
|
2746
|
+
connectionKey,
|
|
2747
|
+
expireTimeMs,
|
|
2748
|
+
};
|
|
2749
|
+
}
|
|
2750
|
+
catch (err) {
|
|
2751
|
+
console.error('[SubscriptionController] An error occurred while claiming an activation key:', err);
|
|
2752
|
+
return {
|
|
2753
|
+
success: false,
|
|
2754
|
+
errorCode: 'server_error',
|
|
2755
|
+
errorMessage: 'A server error occurred.',
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
/**
|
|
2760
|
+
* Handles the webhook from Stripe for updating the internal database.
|
|
2761
|
+
*/
|
|
2762
|
+
async handleStripeWebhook(request) {
|
|
2763
|
+
if (!this._stripe) {
|
|
2764
|
+
return {
|
|
2765
|
+
success: false,
|
|
2766
|
+
errorCode: 'not_supported',
|
|
2767
|
+
errorMessage: 'This method is not supported.',
|
|
2768
|
+
};
|
|
2769
|
+
}
|
|
2770
|
+
try {
|
|
2771
|
+
if (typeof request.requestBody !== 'string' ||
|
|
2772
|
+
request.requestBody === '') {
|
|
2773
|
+
return {
|
|
2774
|
+
success: false,
|
|
2775
|
+
errorCode: 'invalid_request',
|
|
2776
|
+
errorMessage: 'The request was not valid.',
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
if (typeof request.signature !== 'string' ||
|
|
2780
|
+
request.signature === '') {
|
|
2781
|
+
return {
|
|
2782
|
+
success: false,
|
|
2783
|
+
errorCode: 'invalid_request',
|
|
2784
|
+
errorMessage: 'The request was not valid.',
|
|
2785
|
+
};
|
|
2786
|
+
}
|
|
2787
|
+
const config = await this._getConfig();
|
|
2788
|
+
const body = request.requestBody;
|
|
2789
|
+
const signature = request.signature;
|
|
2790
|
+
let event;
|
|
2791
|
+
try {
|
|
2792
|
+
event = this._stripe.constructWebhookEvent(body, signature, config.webhookSecret);
|
|
2793
|
+
}
|
|
2794
|
+
catch (err) {
|
|
2795
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] Unable to construct webhook event:`, err);
|
|
2796
|
+
return {
|
|
2797
|
+
success: false,
|
|
2798
|
+
errorCode: 'invalid_request',
|
|
2799
|
+
errorMessage: 'The request was not valid.',
|
|
2800
|
+
};
|
|
2801
|
+
}
|
|
2802
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] Got event: ${event.type}`);
|
|
2803
|
+
if (event.type === 'customer.subscription.created' ||
|
|
2804
|
+
event.type === 'customer.subscription.deleted' ||
|
|
2805
|
+
event.type === 'customer.subscription.updated') {
|
|
2806
|
+
const subscription = event.data.object;
|
|
2807
|
+
return await this._handleStripeSubscriptionEvent(config, event, subscription);
|
|
2808
|
+
}
|
|
2809
|
+
else if (event.type === 'invoice.paid') {
|
|
2810
|
+
const parseResult = STRIPE_EVENT_INVOICE_PAID_SCHEMA.safeParse(event);
|
|
2811
|
+
if (parseResult.success === false) {
|
|
2812
|
+
console.error(`[SubscriptionController] [handleStripeWebhook] Unable to parse stripe event!`, parseResult.error);
|
|
2813
|
+
return {
|
|
2814
|
+
success: true,
|
|
2815
|
+
};
|
|
2816
|
+
}
|
|
2817
|
+
return await this._handleStripeInvoicePaidEvent(config, event, parseResult.data.data.object);
|
|
2818
|
+
}
|
|
2819
|
+
else if (event.type === 'account.updated') {
|
|
2820
|
+
const parseResult = STRIPE_EVENT_ACCOUNT_UPDATED_SCHEMA.safeParse(event);
|
|
2821
|
+
if (parseResult.success === false) {
|
|
2822
|
+
console.error(`[SubscriptionController] [handleStripeWebhook] Unable to parse stripe event!`, parseResult.error);
|
|
2823
|
+
return {
|
|
2824
|
+
success: false,
|
|
2825
|
+
errorCode: 'invalid_request',
|
|
2826
|
+
errorMessage: 'The request was not able to be parsed.',
|
|
2827
|
+
};
|
|
2828
|
+
}
|
|
2829
|
+
return await this._handleStripeAccountUpdatedEvent(config, parseResult.data);
|
|
2830
|
+
}
|
|
2831
|
+
else if (event.type === 'checkout.session.completed' ||
|
|
2832
|
+
event.type === 'checkout.session.expired' ||
|
|
2833
|
+
event.type === 'checkout.session.async_payment_failed' ||
|
|
2834
|
+
event.type === 'checkout.session.async_payment_succeeded') {
|
|
2835
|
+
const parseResult = STRIPE_EVENT_CHECKOUT_SESSION_SCHEMA.safeParse(event);
|
|
2836
|
+
if (parseResult.success === false) {
|
|
2837
|
+
console.error(`[SubscriptionController] [handleStripeWebhook] Unable to parse stripe event!`, parseResult.error);
|
|
2838
|
+
return {
|
|
2839
|
+
success: false,
|
|
2840
|
+
errorCode: 'invalid_request',
|
|
2841
|
+
errorMessage: 'The request was not able to be parsed.',
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
2844
|
+
return await this._handleStripeCheckoutSessionEvent(config, event, parseResult.data);
|
|
2845
|
+
}
|
|
2846
|
+
return {
|
|
2847
|
+
success: true,
|
|
2848
|
+
};
|
|
2849
|
+
}
|
|
2850
|
+
catch (err) {
|
|
2851
|
+
const span = trace.getActiveSpan();
|
|
2852
|
+
span?.recordException(err);
|
|
2853
|
+
console.error('[SubscriptionController] An error occurred while handling a stripe webhook:', err);
|
|
2854
|
+
return {
|
|
2855
|
+
success: false,
|
|
2856
|
+
errorCode: 'server_error',
|
|
2857
|
+
errorMessage: 'A server error occurred.',
|
|
2858
|
+
};
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
async _handleStripeCheckoutSessionEvent(config, event, sessionEvent) {
|
|
2862
|
+
const stripeSession = sessionEvent.data.object;
|
|
2863
|
+
const sessionId = stripeSession.client_reference_id;
|
|
2864
|
+
if (!sessionId) {
|
|
2865
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] No client_reference_id found in the event.`);
|
|
2866
|
+
return {
|
|
2867
|
+
success: true,
|
|
2868
|
+
};
|
|
2869
|
+
}
|
|
2870
|
+
const session = await this._authStore.getCheckoutSessionById(sessionId);
|
|
2871
|
+
if (!session) {
|
|
2872
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] Could not find session with ID (${sessionId}).`);
|
|
2873
|
+
return {
|
|
2874
|
+
success: false,
|
|
2875
|
+
errorCode: 'invalid_request',
|
|
2876
|
+
errorMessage: 'The session could not be found.',
|
|
2877
|
+
};
|
|
2878
|
+
}
|
|
2879
|
+
if (session.stripeCheckoutSessionId !== stripeSession.id) {
|
|
2880
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] Stripe checkout session ID (${stripeSession.id}) does not match stored ID (${session.stripeCheckoutSessionId}).`);
|
|
2881
|
+
return {
|
|
2882
|
+
success: false,
|
|
2883
|
+
errorCode: 'invalid_request',
|
|
2884
|
+
errorMessage: 'The session ID does not match the expected session ID.',
|
|
2885
|
+
};
|
|
2886
|
+
}
|
|
2887
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] [sessionId: ${sessionId} stripeCheckoutSessionId: ${stripeSession.id} status: ${stripeSession.status} paymentStatus: ${stripeSession.payment_status}] Checkout session updated for session ID.`);
|
|
2888
|
+
const paid = stripeSession.payment_status === 'no_payment_required' ||
|
|
2889
|
+
stripeSession.payment_status === 'paid';
|
|
2890
|
+
await this._authStore.updateCheckoutSessionInfo({
|
|
2891
|
+
...session,
|
|
2892
|
+
paid,
|
|
2893
|
+
paymentStatus: stripeSession.payment_status,
|
|
2894
|
+
status: stripeSession.status,
|
|
2895
|
+
invoice: null,
|
|
2896
|
+
});
|
|
2897
|
+
if (event.type === 'checkout.session.expired') {
|
|
2898
|
+
if (session.transferIds && session.transfersPending) {
|
|
2899
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] [sessionId: ${sessionId} stripeCheckoutSessionId: ${stripeSession.id} transferIds: [${session.transferIds.join(',')}]] Voiding pending transfers.`);
|
|
2900
|
+
const result = await this._financialController.completePendingTransfers({
|
|
2901
|
+
transfers: session.transferIds,
|
|
2902
|
+
transactionId: session.transactionId,
|
|
2903
|
+
flags: TransferFlags.void_pending_transfer,
|
|
2904
|
+
});
|
|
2905
|
+
if (isFailure(result)) {
|
|
2906
|
+
logError(result.error, `[SubscriptionController] [handleStripeWebhook] Failed to void pending transfers for session ID: ${sessionId}`);
|
|
2907
|
+
return {
|
|
2908
|
+
success: false,
|
|
2909
|
+
errorCode: 'server_error',
|
|
2910
|
+
errorMessage: 'Failed to void pending transfers for session ID.',
|
|
2911
|
+
};
|
|
2912
|
+
}
|
|
2913
|
+
else {
|
|
2914
|
+
await this._authStore.updateCheckoutSessionInfo({
|
|
2915
|
+
...session,
|
|
2916
|
+
paid,
|
|
2917
|
+
paymentStatus: stripeSession.payment_status,
|
|
2918
|
+
status: stripeSession.status,
|
|
2919
|
+
invoice: null,
|
|
2920
|
+
transfersPending: false,
|
|
2921
|
+
});
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
if (paid &&
|
|
2926
|
+
!session.fulfilledAtMs &&
|
|
2927
|
+
session.shouldBeAutomaticallyFulfilled) {
|
|
2928
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] [sessionId: ${sessionId} stripeCheckoutSessionId: ${stripeSession.id}] Automatically fulfilling checkout session.`);
|
|
2929
|
+
const result = await this.fulfillCheckoutSession({
|
|
2930
|
+
userId: session.userId,
|
|
2931
|
+
activation: 'now',
|
|
2932
|
+
sessionId,
|
|
2933
|
+
});
|
|
2934
|
+
if (result.success === false) {
|
|
2935
|
+
console.error(`[SubscriptionController] [handleStripeWebhook] Failed to fulfill checkout session:`, result);
|
|
2936
|
+
return {
|
|
2937
|
+
success: false,
|
|
2938
|
+
errorCode: 'server_error',
|
|
2939
|
+
errorMessage: 'Failed to fulfill checkout session for session ID.',
|
|
2940
|
+
};
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
return {
|
|
2944
|
+
success: true,
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
async _handleStripeAccountUpdatedEvent(config, event) {
|
|
2948
|
+
const accountId = event.data.object.id;
|
|
2949
|
+
const account = await this._stripe.getAccountById(accountId);
|
|
2950
|
+
let studio = await this._recordsStore.getStudioByStripeAccountId(accountId);
|
|
2951
|
+
if (studio) {
|
|
2952
|
+
return await this._handleStudioStripeAccountUpdatedEvent(account, studio);
|
|
2953
|
+
}
|
|
2954
|
+
const user = await this._authStore.findUserByStripeAccountId(accountId);
|
|
2955
|
+
if (user) {
|
|
2956
|
+
return await this._handleUserStripeAccountUpdatedEvent(account, user);
|
|
2957
|
+
}
|
|
2958
|
+
console.warn(`[SubscriptionController] [handleStripeWebhook] No user or studio found for account ID (${accountId}).`);
|
|
2959
|
+
return {
|
|
2960
|
+
success: true,
|
|
2961
|
+
};
|
|
2962
|
+
}
|
|
2963
|
+
async _handleStudioStripeAccountUpdatedEvent(account, studio) {
|
|
2964
|
+
if (!studio) {
|
|
2965
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] No studio found for account ID (${account.id}).`);
|
|
2966
|
+
return {
|
|
2967
|
+
success: true,
|
|
2968
|
+
};
|
|
2969
|
+
}
|
|
2970
|
+
const newStatus = getAccountStatus(account);
|
|
2971
|
+
const newRequirementsStatus = getAccountRequirementsStatus(account);
|
|
2972
|
+
if (studio.stripeAccountStatus !== newStatus ||
|
|
2973
|
+
studio.stripeAccountRequirementsStatus !== newRequirementsStatus) {
|
|
2974
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] Updating studio (${studio.id}) account status to ${newStatus} and requirements status to ${newRequirementsStatus}.`);
|
|
2975
|
+
studio = {
|
|
2976
|
+
...studio,
|
|
2977
|
+
stripeAccountStatus: newStatus,
|
|
2978
|
+
stripeAccountRequirementsStatus: newRequirementsStatus,
|
|
2979
|
+
};
|
|
2980
|
+
await this._recordsStore.updateStudio(studio);
|
|
2981
|
+
}
|
|
2982
|
+
return {
|
|
2983
|
+
success: true,
|
|
2984
|
+
};
|
|
2985
|
+
}
|
|
2986
|
+
async _handleUserStripeAccountUpdatedEvent(account, user) {
|
|
2987
|
+
if (!user) {
|
|
2988
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] No user found for account ID (${account.id}).`);
|
|
2989
|
+
return {
|
|
2990
|
+
success: true,
|
|
2991
|
+
};
|
|
2992
|
+
}
|
|
2993
|
+
const newStatus = getAccountStatus(account);
|
|
2994
|
+
const newRequirementsStatus = getAccountRequirementsStatus(account);
|
|
2995
|
+
if (user.stripeAccountStatus !== newStatus ||
|
|
2996
|
+
user.stripeAccountRequirementsStatus !== newRequirementsStatus) {
|
|
2997
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] Updating user (${user.id}) account status to ${newStatus} and requirements status to ${newRequirementsStatus}.`);
|
|
2998
|
+
user = {
|
|
2999
|
+
...user,
|
|
3000
|
+
stripeAccountStatus: newStatus,
|
|
3001
|
+
stripeAccountRequirementsStatus: newRequirementsStatus,
|
|
3002
|
+
};
|
|
3003
|
+
await this._authStore.saveUser(user);
|
|
3004
|
+
}
|
|
3005
|
+
return {
|
|
3006
|
+
success: true,
|
|
3007
|
+
};
|
|
3008
|
+
}
|
|
3009
|
+
async _handleStripeInvoicePaidEvent(config, event, invoice) {
|
|
3010
|
+
const stripeSubscriptionId = invoice.subscription;
|
|
3011
|
+
if (stripeSubscriptionId) {
|
|
3012
|
+
const subscription = await this._stripe.getSubscriptionById(stripeSubscriptionId);
|
|
3013
|
+
const status = subscription.status;
|
|
3014
|
+
const customerId = invoice.customer;
|
|
3015
|
+
const lineItems = invoice.lines.data;
|
|
3016
|
+
const periodStartMs = subscription.current_period_start * 1000;
|
|
3017
|
+
const periodEndMs = subscription.current_period_end * 1000;
|
|
3018
|
+
const { sub, item } = findMatchingSubscription(lineItems);
|
|
3019
|
+
const authInvoice = {
|
|
3020
|
+
currency: invoice.currency,
|
|
3021
|
+
description: invoice.description,
|
|
3022
|
+
paid: invoice.paid,
|
|
3023
|
+
status: invoice.status,
|
|
3024
|
+
tax: invoice.tax,
|
|
3025
|
+
total: invoice.total,
|
|
3026
|
+
subtotal: invoice.subtotal,
|
|
3027
|
+
stripeInvoiceId: invoice.id,
|
|
3028
|
+
stripeHostedInvoiceUrl: invoice.hosted_invoice_url,
|
|
3029
|
+
stripeInvoicePdfUrl: invoice.invoice_pdf,
|
|
3030
|
+
};
|
|
3031
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] New invoice paid for customer ID (${customerId}). Subscription ID: ${subscription.id}. Period start: ${periodStartMs}. Period end: ${periodEndMs}.`);
|
|
3032
|
+
if (!sub) {
|
|
3033
|
+
console.error(`[SubscriptionController] [handleStripeWebhook] No matching subscription found for invoice (${invoice.id}).`);
|
|
3034
|
+
return {
|
|
3035
|
+
success: false,
|
|
3036
|
+
errorCode: 'server_error',
|
|
3037
|
+
errorMessage: 'No matching subscription found.',
|
|
3038
|
+
};
|
|
3039
|
+
}
|
|
3040
|
+
const user = await this._authStore.findUserByStripeCustomerId(customerId);
|
|
3041
|
+
if (user) {
|
|
3042
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] Found user (${user.id}) with customer ID (${customerId}).`);
|
|
3043
|
+
await this._authStore.updateSubscriptionPeriod({
|
|
3044
|
+
userId: user.id,
|
|
3045
|
+
subscriptionStatus: status,
|
|
3046
|
+
subscriptionId: sub.id,
|
|
3047
|
+
stripeSubscriptionId,
|
|
3048
|
+
stripeCustomerId: customerId,
|
|
3049
|
+
currentPeriodEndMs: periodEndMs,
|
|
3050
|
+
currentPeriodStartMs: periodStartMs,
|
|
3051
|
+
invoice: authInvoice,
|
|
3052
|
+
});
|
|
3053
|
+
const creditResult = await this._internalTransactionPurchaseCreditsStripe(sub, invoice, {
|
|
3054
|
+
userId: user.id,
|
|
3055
|
+
});
|
|
3056
|
+
if (isFailure(creditResult)) {
|
|
3057
|
+
return genericResult(creditResult);
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
else {
|
|
3061
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] No user found for customer ID (${customerId}).`);
|
|
3062
|
+
const studio = await this._recordsStore.getStudioByStripeCustomerId(customerId);
|
|
3063
|
+
if (studio) {
|
|
3064
|
+
await this._authStore.updateSubscriptionPeriod({
|
|
3065
|
+
studioId: studio.id,
|
|
3066
|
+
subscriptionStatus: status,
|
|
3067
|
+
subscriptionId: sub.id,
|
|
3068
|
+
stripeSubscriptionId,
|
|
3069
|
+
stripeCustomerId: customerId,
|
|
774
3070
|
currentPeriodEndMs: periodEndMs,
|
|
775
3071
|
currentPeriodStartMs: periodStartMs,
|
|
776
3072
|
invoice: authInvoice,
|
|
777
3073
|
});
|
|
3074
|
+
const creditResult = await this._internalTransactionPurchaseCreditsStripe(sub, invoice, {
|
|
3075
|
+
studioId: studio.id,
|
|
3076
|
+
});
|
|
3077
|
+
if (isFailure(creditResult)) {
|
|
3078
|
+
return genericResult(creditResult);
|
|
3079
|
+
}
|
|
778
3080
|
}
|
|
779
3081
|
else {
|
|
780
|
-
console.log(`[SubscriptionController] [handleStripeWebhook] No
|
|
781
|
-
const studio = await this._recordsStore.getStudioByStripeCustomerId(customerId);
|
|
782
|
-
if (studio) {
|
|
783
|
-
await this._authStore.updateSubscriptionPeriod({
|
|
784
|
-
studioId: studio.id,
|
|
785
|
-
subscriptionStatus: status,
|
|
786
|
-
subscriptionId: sub.id,
|
|
787
|
-
stripeSubscriptionId,
|
|
788
|
-
stripeCustomerId: customerId,
|
|
789
|
-
currentPeriodEndMs: periodEndMs,
|
|
790
|
-
currentPeriodStartMs: periodStartMs,
|
|
791
|
-
invoice: authInvoice,
|
|
792
|
-
});
|
|
793
|
-
}
|
|
794
|
-
else {
|
|
795
|
-
console.log(`[SubscriptionController] [handleStripeWebhook] No studio found for customer ID (${customerId}).`);
|
|
796
|
-
}
|
|
3082
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] No studio found for customer ID (${customerId}).`);
|
|
797
3083
|
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
3084
|
+
}
|
|
3085
|
+
function findMatchingSubscription(lineItems) {
|
|
3086
|
+
let item;
|
|
3087
|
+
let sub;
|
|
3088
|
+
items_loop: for (let i of lineItems) {
|
|
3089
|
+
for (let s of config.subscriptions) {
|
|
3090
|
+
if ((s.eligibleProducts &&
|
|
3091
|
+
s.eligibleProducts.some((p) => p === i.price.product)) ||
|
|
3092
|
+
s.product === i.price.product) {
|
|
3093
|
+
sub = s;
|
|
3094
|
+
item = i;
|
|
3095
|
+
break items_loop;
|
|
809
3096
|
}
|
|
810
3097
|
}
|
|
811
|
-
return { item, sub };
|
|
812
3098
|
}
|
|
3099
|
+
return { item, sub };
|
|
813
3100
|
}
|
|
814
|
-
return {
|
|
815
|
-
success: true,
|
|
816
|
-
};
|
|
817
3101
|
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
3102
|
+
else {
|
|
3103
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] No subscription ID found in invoice.`);
|
|
3104
|
+
const authInvoice = await this._authStore.getInvoiceByStripeId(invoice.id);
|
|
3105
|
+
if (!authInvoice) {
|
|
3106
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] No invoice found for stripe ID (${invoice.id}).`);
|
|
3107
|
+
return {
|
|
3108
|
+
success: true,
|
|
3109
|
+
};
|
|
3110
|
+
}
|
|
3111
|
+
await this._authStore.saveInvoice({
|
|
3112
|
+
...authInvoice,
|
|
3113
|
+
currency: invoice.currency,
|
|
3114
|
+
description: invoice.description,
|
|
3115
|
+
paid: invoice.paid,
|
|
3116
|
+
status: invoice.status,
|
|
3117
|
+
tax: invoice.tax,
|
|
3118
|
+
total: invoice.total,
|
|
3119
|
+
subtotal: invoice.subtotal,
|
|
3120
|
+
});
|
|
3121
|
+
}
|
|
3122
|
+
return {
|
|
3123
|
+
success: true,
|
|
3124
|
+
};
|
|
3125
|
+
}
|
|
3126
|
+
async _internalTransactionPurchaseCreditsStripe(sub, invoice, accountFilter) {
|
|
3127
|
+
const creditGrant = sub.creditGrant ?? 0;
|
|
3128
|
+
if (creditGrant === 0 || !this._financialController) {
|
|
3129
|
+
return success();
|
|
3130
|
+
}
|
|
3131
|
+
const account = await this._financialController.getOrCreateFinancialAccount({
|
|
3132
|
+
...accountFilter,
|
|
3133
|
+
ledger: LEDGERS.credits,
|
|
3134
|
+
});
|
|
3135
|
+
if (isFailure(account)) {
|
|
3136
|
+
logError(account.error, `[SubscriptionController] [_internalTransactionPurchaseCreditsStripe invoice: ${invoice.id}] Unable to get or create credit account!`);
|
|
3137
|
+
return failure({
|
|
824
3138
|
errorCode: 'server_error',
|
|
825
3139
|
errorMessage: 'A server error occurred.',
|
|
3140
|
+
});
|
|
3141
|
+
}
|
|
3142
|
+
const accountId = account.value.account.id;
|
|
3143
|
+
let creditAmount;
|
|
3144
|
+
if (creditGrant === 'match-invoice') {
|
|
3145
|
+
const converted = convertBetweenLedgers(LEDGERS.usd, LEDGERS.credits, BigInt(invoice.total));
|
|
3146
|
+
if (converted.remainder > 0n) {
|
|
3147
|
+
console.warn(`[SubscriptionController] [_internalTransactionPurchaseCreditsStripe invoice: ${invoice.id} account: ${accountId}] Rounding down remainder when converting invoice amount to credits.`);
|
|
3148
|
+
}
|
|
3149
|
+
creditAmount = converted.value;
|
|
3150
|
+
}
|
|
3151
|
+
else {
|
|
3152
|
+
creditAmount = BigInt(creditGrant);
|
|
3153
|
+
}
|
|
3154
|
+
if (creditAmount > 0) {
|
|
3155
|
+
const transactionResult = await this._financialController.internalTransaction({
|
|
3156
|
+
transfers: [
|
|
3157
|
+
{
|
|
3158
|
+
amount: invoice.total,
|
|
3159
|
+
code: TransferCodes.purchase_credits,
|
|
3160
|
+
debitAccountId: ACCOUNT_IDS.assets_stripe,
|
|
3161
|
+
creditAccountId: ACCOUNT_IDS.liquidity_usd,
|
|
3162
|
+
currency: 'usd',
|
|
3163
|
+
},
|
|
3164
|
+
{
|
|
3165
|
+
amount: creditAmount,
|
|
3166
|
+
code: TransferCodes.purchase_credits,
|
|
3167
|
+
debitAccountId: ACCOUNT_IDS.liquidity_credits,
|
|
3168
|
+
creditAccountId: accountId,
|
|
3169
|
+
currency: 'credits',
|
|
3170
|
+
},
|
|
3171
|
+
],
|
|
3172
|
+
});
|
|
3173
|
+
if (isFailure(transactionResult)) {
|
|
3174
|
+
logError(transactionResult.error, `[SubscriptionController] [_internalTransactionPurchaseCreditsStripe invoice: ${invoice.id} account: ${accountId}] Unable to record credit grant for invoice!`);
|
|
3175
|
+
return failure({
|
|
3176
|
+
errorCode: 'server_error',
|
|
3177
|
+
errorMessage: 'Unable to record credit grant for invoice.',
|
|
3178
|
+
});
|
|
3179
|
+
}
|
|
3180
|
+
console.log(`[SubscriptionController] [_internalTransactionPurchaseCreditsStripe invoice: ${invoice.id} account: ${accountId}] Granted ${creditAmount} credits for invoice (${invoice.id}).`);
|
|
3181
|
+
}
|
|
3182
|
+
else {
|
|
3183
|
+
console.warn(`[SubscriptionController] [_internalTransactionPurchaseCreditsStripe invoice: ${invoice.id} account: ${accountId}] No credits granted for invoice (${invoice.id}).`);
|
|
3184
|
+
}
|
|
3185
|
+
return success();
|
|
3186
|
+
}
|
|
3187
|
+
async _handleStripeSubscriptionEvent(config, event, subscription) {
|
|
3188
|
+
const items = subscription.items.data;
|
|
3189
|
+
let item;
|
|
3190
|
+
let sub;
|
|
3191
|
+
items_loop: for (let i of items) {
|
|
3192
|
+
for (let s of config.subscriptions) {
|
|
3193
|
+
if (s.eligibleProducts &&
|
|
3194
|
+
s.eligibleProducts.some((p) => p === i.price.product)) {
|
|
3195
|
+
sub = s;
|
|
3196
|
+
item = i;
|
|
3197
|
+
break items_loop;
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
if (!item || !sub) {
|
|
3202
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] No item in the subscription matches an eligible product in the config.`);
|
|
3203
|
+
return {
|
|
3204
|
+
success: true,
|
|
826
3205
|
};
|
|
827
3206
|
}
|
|
3207
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] Subscription (${sub.id}) found!`);
|
|
3208
|
+
const status = subscription.status;
|
|
3209
|
+
const active = isActiveSubscription(status);
|
|
3210
|
+
const tier = sub.tier ?? 'beta';
|
|
3211
|
+
const customerId = subscription.customer;
|
|
3212
|
+
const stripeSubscriptionId = subscription.id;
|
|
3213
|
+
const periodStartMs = subscription.current_period_start * 1000;
|
|
3214
|
+
const periodEndMs = subscription.current_period_end * 1000;
|
|
3215
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] Customer ID: ${customerId}. Subscription status: ${status}. Tier: ${tier}. Is Active: ${active}.`);
|
|
3216
|
+
let user = await this._authStore.findUserByStripeCustomerId(customerId);
|
|
3217
|
+
let studio;
|
|
3218
|
+
if (user) {
|
|
3219
|
+
await this._authStore.updateSubscriptionInfo({
|
|
3220
|
+
userId: user.id,
|
|
3221
|
+
subscriptionStatus: status,
|
|
3222
|
+
subscriptionId: sub.id,
|
|
3223
|
+
stripeSubscriptionId,
|
|
3224
|
+
stripeCustomerId: customerId,
|
|
3225
|
+
currentPeriodEndMs: periodEndMs,
|
|
3226
|
+
currentPeriodStartMs: periodStartMs,
|
|
3227
|
+
});
|
|
3228
|
+
}
|
|
3229
|
+
else {
|
|
3230
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] No user found for Customer ID (${customerId})`);
|
|
3231
|
+
studio = await this._recordsStore.getStudioByStripeCustomerId(customerId);
|
|
3232
|
+
if (studio) {
|
|
3233
|
+
await this._authStore.updateSubscriptionInfo({
|
|
3234
|
+
studioId: studio.id,
|
|
3235
|
+
subscriptionStatus: status,
|
|
3236
|
+
subscriptionId: sub.id,
|
|
3237
|
+
stripeSubscriptionId,
|
|
3238
|
+
stripeCustomerId: customerId,
|
|
3239
|
+
currentPeriodEndMs: periodEndMs,
|
|
3240
|
+
currentPeriodStartMs: periodStartMs,
|
|
3241
|
+
});
|
|
3242
|
+
}
|
|
3243
|
+
else {
|
|
3244
|
+
console.log(`[SubscriptionController] [handleStripeWebhook] No studio found for Customer ID (${customerId})`);
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
return {
|
|
3248
|
+
success: true,
|
|
3249
|
+
};
|
|
828
3250
|
}
|
|
829
3251
|
}
|
|
830
3252
|
__decorate([
|
|
831
3253
|
traced(TRACE_NAME)
|
|
832
3254
|
], SubscriptionController.prototype, "getSubscriptionStatus", null);
|
|
3255
|
+
__decorate([
|
|
3256
|
+
traced(TRACE_NAME)
|
|
3257
|
+
], SubscriptionController.prototype, "getBalances", null);
|
|
3258
|
+
__decorate([
|
|
3259
|
+
traced(TRACE_NAME)
|
|
3260
|
+
], SubscriptionController.prototype, "listAccountTransfers", null);
|
|
833
3261
|
__decorate([
|
|
834
3262
|
traced(TRACE_NAME)
|
|
835
3263
|
], SubscriptionController.prototype, "updateSubscription", null);
|
|
836
3264
|
__decorate([
|
|
837
3265
|
traced(TRACE_NAME)
|
|
838
3266
|
], SubscriptionController.prototype, "createManageSubscriptionLink", null);
|
|
3267
|
+
__decorate([
|
|
3268
|
+
traced(TRACE_NAME)
|
|
3269
|
+
], SubscriptionController.prototype, "createManageStoreAccountLink", null);
|
|
3270
|
+
__decorate([
|
|
3271
|
+
traced(TRACE_NAME)
|
|
3272
|
+
], SubscriptionController.prototype, "createManageXpAccountLink", null);
|
|
3273
|
+
__decorate([
|
|
3274
|
+
traced(TRACE_NAME)
|
|
3275
|
+
], SubscriptionController.prototype, "createStripeLoginLink", null);
|
|
3276
|
+
__decorate([
|
|
3277
|
+
traced(TRACE_NAME)
|
|
3278
|
+
], SubscriptionController.prototype, "createStripeAccountSession", null);
|
|
839
3279
|
__decorate([
|
|
840
3280
|
traced(TRACE_NAME)
|
|
841
3281
|
], SubscriptionController.prototype, "_createCheckoutSession", null);
|
|
3282
|
+
__decorate([
|
|
3283
|
+
traced(TRACE_NAME)
|
|
3284
|
+
], SubscriptionController.prototype, "createPurchaseItemLink", null);
|
|
3285
|
+
__decorate([
|
|
3286
|
+
traced(TRACE_NAME)
|
|
3287
|
+
], SubscriptionController.prototype, "_getContractPurchaseDetails", null);
|
|
3288
|
+
__decorate([
|
|
3289
|
+
traced(TRACE_NAME)
|
|
3290
|
+
], SubscriptionController.prototype, "purchaseContract", null);
|
|
3291
|
+
__decorate([
|
|
3292
|
+
traced(TRACE_NAME)
|
|
3293
|
+
], SubscriptionController.prototype, "cancelContract", null);
|
|
3294
|
+
__decorate([
|
|
3295
|
+
traced(TRACE_NAME)
|
|
3296
|
+
], SubscriptionController.prototype, "invoiceContract", null);
|
|
3297
|
+
__decorate([
|
|
3298
|
+
traced(TRACE_NAME)
|
|
3299
|
+
], SubscriptionController.prototype, "cancelInvoice", null);
|
|
3300
|
+
__decorate([
|
|
3301
|
+
traced(TRACE_NAME)
|
|
3302
|
+
], SubscriptionController.prototype, "listContractInvoices", null);
|
|
3303
|
+
__decorate([
|
|
3304
|
+
traced(TRACE_NAME)
|
|
3305
|
+
], SubscriptionController.prototype, "payContractInvoice", null);
|
|
3306
|
+
__decorate([
|
|
3307
|
+
traced(TRACE_NAME)
|
|
3308
|
+
], SubscriptionController.prototype, "payoutAccount", null);
|
|
3309
|
+
__decorate([
|
|
3310
|
+
traced(TRACE_NAME)
|
|
3311
|
+
], SubscriptionController.prototype, "_payoutToStripe", null);
|
|
3312
|
+
__decorate([
|
|
3313
|
+
traced(TRACE_NAME)
|
|
3314
|
+
], SubscriptionController.prototype, "_payoutToCash", null);
|
|
3315
|
+
__decorate([
|
|
3316
|
+
traced(TRACE_NAME)
|
|
3317
|
+
], SubscriptionController.prototype, "fulfillCheckoutSession", null);
|
|
3318
|
+
__decorate([
|
|
3319
|
+
traced(TRACE_NAME)
|
|
3320
|
+
], SubscriptionController.prototype, "claimActivationKey", null);
|
|
842
3321
|
__decorate([
|
|
843
3322
|
traced(TRACE_NAME)
|
|
844
3323
|
], SubscriptionController.prototype, "handleStripeWebhook", null);
|
|
3324
|
+
__decorate([
|
|
3325
|
+
traced(TRACE_NAME)
|
|
3326
|
+
], SubscriptionController.prototype, "_handleStripeCheckoutSessionEvent", null);
|
|
3327
|
+
__decorate([
|
|
3328
|
+
traced(TRACE_NAME)
|
|
3329
|
+
], SubscriptionController.prototype, "_handleStripeAccountUpdatedEvent", null);
|
|
3330
|
+
__decorate([
|
|
3331
|
+
traced(TRACE_NAME)
|
|
3332
|
+
], SubscriptionController.prototype, "_handleStudioStripeAccountUpdatedEvent", null);
|
|
3333
|
+
__decorate([
|
|
3334
|
+
traced(TRACE_NAME)
|
|
3335
|
+
], SubscriptionController.prototype, "_handleUserStripeAccountUpdatedEvent", null);
|
|
3336
|
+
__decorate([
|
|
3337
|
+
traced(TRACE_NAME)
|
|
3338
|
+
], SubscriptionController.prototype, "_handleStripeInvoicePaidEvent", null);
|
|
3339
|
+
__decorate([
|
|
3340
|
+
traced(TRACE_NAME)
|
|
3341
|
+
], SubscriptionController.prototype, "_handleStripeSubscriptionEvent", null);
|
|
3342
|
+
/**
|
|
3343
|
+
* Gets the account status for the given stripe account.
|
|
3344
|
+
* @param account The account that the status should be retrieved for.
|
|
3345
|
+
*/
|
|
3346
|
+
export function getAccountStatus(account) {
|
|
3347
|
+
const disabledReason = account?.requirements?.disabled_reason;
|
|
3348
|
+
if (disabledReason === 'under_review' ||
|
|
3349
|
+
disabledReason === 'requirements.pending_verification') {
|
|
3350
|
+
return 'pending';
|
|
3351
|
+
}
|
|
3352
|
+
else if (disabledReason === 'rejected.fraud' ||
|
|
3353
|
+
disabledReason === 'rejected.incomplete_verification' ||
|
|
3354
|
+
disabledReason === 'rejected.listed' ||
|
|
3355
|
+
disabledReason === 'rejected.other' ||
|
|
3356
|
+
disabledReason === 'rejected.terms_of_service') {
|
|
3357
|
+
return 'rejected';
|
|
3358
|
+
}
|
|
3359
|
+
else if (disabledReason) {
|
|
3360
|
+
return 'disabled';
|
|
3361
|
+
}
|
|
3362
|
+
else if (account.charges_enabled) {
|
|
3363
|
+
return 'active';
|
|
3364
|
+
}
|
|
3365
|
+
return 'pending';
|
|
3366
|
+
}
|
|
3367
|
+
/**
|
|
3368
|
+
* Gets the requirements status for the given stripe account.
|
|
3369
|
+
* @param account The account.
|
|
3370
|
+
*/
|
|
3371
|
+
export function getAccountRequirementsStatus(account) {
|
|
3372
|
+
const requirements = account?.requirements;
|
|
3373
|
+
if (!requirements) {
|
|
3374
|
+
return 'incomplete';
|
|
3375
|
+
}
|
|
3376
|
+
if (requirements.currently_due?.length > 0 ||
|
|
3377
|
+
requirements.past_due?.length > 0) {
|
|
3378
|
+
return 'incomplete';
|
|
3379
|
+
}
|
|
3380
|
+
return 'complete';
|
|
3381
|
+
}
|
|
845
3382
|
function returnRoute(basePath, user, studio) {
|
|
846
3383
|
if (user) {
|
|
847
3384
|
return basePath;
|
|
@@ -853,4 +3390,267 @@ function returnRoute(basePath, user, studio) {
|
|
|
853
3390
|
function studiosRoute(basePath, studioId, studioName) {
|
|
854
3391
|
return new URL(`/studios/${encodeURIComponent(studioId)}/${encodeURIComponent(studioName)}`, basePath).href;
|
|
855
3392
|
}
|
|
3393
|
+
function fulfillmentRoute(basePath, sessionId) {
|
|
3394
|
+
return new URL(`/store/fulfillment/${sessionId}`, basePath).href;
|
|
3395
|
+
}
|
|
3396
|
+
function activationRoute(basePath, key) {
|
|
3397
|
+
const url = new URL(`/store/activate`, basePath);
|
|
3398
|
+
url.searchParams.set('key', key);
|
|
3399
|
+
return url.href;
|
|
3400
|
+
}
|
|
3401
|
+
/**
|
|
3402
|
+
* Formats a V1 access key.
|
|
3403
|
+
* @param itemId The ID of the purchased item.
|
|
3404
|
+
* @param secret The secret that should be used to access the purchased item.
|
|
3405
|
+
*/
|
|
3406
|
+
export function formatV1ActivationKey(itemId, secret) {
|
|
3407
|
+
return `vAK1.${toBase64String(itemId)}.${toBase64String(secret)}`;
|
|
3408
|
+
}
|
|
3409
|
+
/**
|
|
3410
|
+
* Parses the given access key.
|
|
3411
|
+
* Returns null if the access key is invalid.
|
|
3412
|
+
* @param key The key to parse.
|
|
3413
|
+
*/
|
|
3414
|
+
export function parseActivationKey(key) {
|
|
3415
|
+
if (!key) {
|
|
3416
|
+
return null;
|
|
3417
|
+
}
|
|
3418
|
+
if (!key.startsWith('vAK1.')) {
|
|
3419
|
+
return null;
|
|
3420
|
+
}
|
|
3421
|
+
const withoutVersion = key.slice('vAK1.'.length);
|
|
3422
|
+
let periodAfterId = withoutVersion.indexOf('.');
|
|
3423
|
+
if (periodAfterId < 0) {
|
|
3424
|
+
return null;
|
|
3425
|
+
}
|
|
3426
|
+
const idBase64 = withoutVersion.slice(0, periodAfterId);
|
|
3427
|
+
const secretBase64 = withoutVersion.slice(periodAfterId + 1);
|
|
3428
|
+
if (idBase64.length <= 0 || secretBase64.length <= 0) {
|
|
3429
|
+
return null;
|
|
3430
|
+
}
|
|
3431
|
+
try {
|
|
3432
|
+
const name = fromBase64String(idBase64);
|
|
3433
|
+
const secret = fromBase64String(secretBase64);
|
|
3434
|
+
return [name, secret];
|
|
3435
|
+
}
|
|
3436
|
+
catch (err) {
|
|
3437
|
+
return null;
|
|
3438
|
+
}
|
|
3439
|
+
}
|
|
3440
|
+
const TRANSFER_CODE_ACTIONS = new Map([
|
|
3441
|
+
[TransferCodes.admin_credit, 'Admin credit'],
|
|
3442
|
+
[TransferCodes.admin_debit, 'Admin debit'],
|
|
3443
|
+
[TransferCodes.account_closing, 'Account closing'],
|
|
3444
|
+
[TransferCodes.purchase_credits, 'Purchase credits'],
|
|
3445
|
+
[TransferCodes.contract_payment, 'Contract payment'],
|
|
3446
|
+
[TransferCodes.contract_refund, 'Contract refund'],
|
|
3447
|
+
[TransferCodes.exchange, 'Exchange'],
|
|
3448
|
+
[TransferCodes.invoice_payment, 'Invoice payment'],
|
|
3449
|
+
[TransferCodes.item_payment, 'Item payment'],
|
|
3450
|
+
[TransferCodes.purchase_credits, 'Credit purchase'],
|
|
3451
|
+
[TransferCodes.reverse_transfer, 'Transfer reversal'],
|
|
3452
|
+
[TransferCodes.store_platform_fee, 'Platform fee'],
|
|
3453
|
+
[TransferCodes.xp_platform_fee, 'Platform fee'],
|
|
3454
|
+
[TransferCodes.user_payout, 'Payout'],
|
|
3455
|
+
[TransferCodes.control, 'Controlling transfer'],
|
|
3456
|
+
[TransferCodes.exchange, 'Exchange'],
|
|
3457
|
+
]);
|
|
3458
|
+
/**
|
|
3459
|
+
* Generates a human readable note for the given transfer.
|
|
3460
|
+
* @param transfer The transfer that should be characterized.
|
|
3461
|
+
*/
|
|
3462
|
+
export function charactarizeTransfer(transfer) {
|
|
3463
|
+
let action;
|
|
3464
|
+
let source;
|
|
3465
|
+
let destination;
|
|
3466
|
+
action = TRANSFER_CODE_ACTIONS.get(transfer.code);
|
|
3467
|
+
if (!action) {
|
|
3468
|
+
return null;
|
|
3469
|
+
}
|
|
3470
|
+
if (transfer.code === TransferCodes.admin_credit) {
|
|
3471
|
+
source = ACCOUNT_NAMES.get(transfer.debit_account_id);
|
|
3472
|
+
destination = ACCOUNT_NAMES.get(transfer.credit_account_id);
|
|
3473
|
+
}
|
|
3474
|
+
else if (transfer.code === TransferCodes.admin_debit) {
|
|
3475
|
+
destination = ACCOUNT_NAMES.get(transfer.credit_account_id);
|
|
3476
|
+
source = ACCOUNT_NAMES.get(transfer.debit_account_id);
|
|
3477
|
+
}
|
|
3478
|
+
else if (transfer.code === TransferCodes.user_payout) {
|
|
3479
|
+
destination = ACCOUNT_NAMES.get(transfer.credit_account_id);
|
|
3480
|
+
}
|
|
3481
|
+
else if (transfer.code === TransferCodes.exchange) {
|
|
3482
|
+
if (transfer.debit_account_id === ACCOUNT_IDS.liquidity_usd ||
|
|
3483
|
+
transfer.credit_account_id === ACCOUNT_IDS.liquidity_credits) {
|
|
3484
|
+
// Exchanging from Credits to USD
|
|
3485
|
+
source = 'Credits';
|
|
3486
|
+
destination = 'USD';
|
|
3487
|
+
}
|
|
3488
|
+
else if (transfer.debit_account_id === ACCOUNT_IDS.liquidity_credits ||
|
|
3489
|
+
transfer.credit_account_id === ACCOUNT_IDS.liquidity_usd) {
|
|
3490
|
+
// Exchanging from USD to Credits
|
|
3491
|
+
source = 'USD';
|
|
3492
|
+
destination = 'Credits';
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
else if (transfer.code === TransferCodes.purchase_credits) {
|
|
3496
|
+
source = ACCOUNT_NAMES.get(transfer.debit_account_id);
|
|
3497
|
+
}
|
|
3498
|
+
else if (transfer.code === TransferCodes.account_closing) {
|
|
3499
|
+
source = ACCOUNT_NAMES.get(transfer.debit_account_id);
|
|
3500
|
+
destination = ACCOUNT_NAMES.get(transfer.credit_account_id);
|
|
3501
|
+
}
|
|
3502
|
+
else if (transfer.code === TransferCodes.contract_payment ||
|
|
3503
|
+
transfer.code === TransferCodes.invoice_payment ||
|
|
3504
|
+
transfer.code === TransferCodes.item_payment) {
|
|
3505
|
+
source = ACCOUNT_NAMES.get(transfer.debit_account_id);
|
|
3506
|
+
destination = ACCOUNT_NAMES.get(transfer.credit_account_id);
|
|
3507
|
+
}
|
|
3508
|
+
else if (transfer.code === TransferCodes.contract_refund) {
|
|
3509
|
+
destination = ACCOUNT_NAMES.get(transfer.credit_account_id);
|
|
3510
|
+
}
|
|
3511
|
+
if (source && destination) {
|
|
3512
|
+
return `${action} from ${source} to ${destination}`;
|
|
3513
|
+
}
|
|
3514
|
+
else if (source) {
|
|
3515
|
+
return `${action} from ${source}`;
|
|
3516
|
+
}
|
|
3517
|
+
else if (destination) {
|
|
3518
|
+
return `${action} to ${destination}`;
|
|
3519
|
+
}
|
|
3520
|
+
else {
|
|
3521
|
+
return action;
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3524
|
+
class TransactionBuilder {
|
|
3525
|
+
constructor() {
|
|
3526
|
+
this.transfers = [];
|
|
3527
|
+
this.lineItems = [];
|
|
3528
|
+
this._pending = false;
|
|
3529
|
+
this._timeoutSeconds = 60 * 60; // 1 hour
|
|
3530
|
+
}
|
|
3531
|
+
usePendingTransfers(timeoutSeconds) {
|
|
3532
|
+
this._pending = true;
|
|
3533
|
+
this._timeoutSeconds = timeoutSeconds;
|
|
3534
|
+
return this;
|
|
3535
|
+
}
|
|
3536
|
+
disablePendingTransfers() {
|
|
3537
|
+
this._pending = false;
|
|
3538
|
+
return this;
|
|
3539
|
+
}
|
|
3540
|
+
addTransfer(tansfer) {
|
|
3541
|
+
this.transfers.push(tansfer);
|
|
3542
|
+
return this;
|
|
3543
|
+
}
|
|
3544
|
+
addItem(item) {
|
|
3545
|
+
if (item.pending ?? this._pending) {
|
|
3546
|
+
this.transfers.push({
|
|
3547
|
+
transferId: item.transferId,
|
|
3548
|
+
amount: item.amount,
|
|
3549
|
+
currency: item.currency,
|
|
3550
|
+
code: item.code,
|
|
3551
|
+
creditAccountId: item.creditAccountId,
|
|
3552
|
+
debitAccountId: item.debitAccountId,
|
|
3553
|
+
pending: true,
|
|
3554
|
+
timeoutSeconds: item.timeoutSeconds ?? this._timeoutSeconds,
|
|
3555
|
+
});
|
|
3556
|
+
}
|
|
3557
|
+
else {
|
|
3558
|
+
this.transfers.push({
|
|
3559
|
+
transferId: item.transferId,
|
|
3560
|
+
amount: item.amount,
|
|
3561
|
+
currency: item.currency,
|
|
3562
|
+
code: item.code,
|
|
3563
|
+
creditAccountId: item.creditAccountId,
|
|
3564
|
+
debitAccountId: item.debitAccountId,
|
|
3565
|
+
});
|
|
3566
|
+
}
|
|
3567
|
+
this.lineItems.push({
|
|
3568
|
+
price_data: {
|
|
3569
|
+
currency: item.currency,
|
|
3570
|
+
unit_amount: item.amount,
|
|
3571
|
+
product_data: {
|
|
3572
|
+
name: item.name,
|
|
3573
|
+
description: item.description,
|
|
3574
|
+
images: [],
|
|
3575
|
+
metadata: item.metadata,
|
|
3576
|
+
},
|
|
3577
|
+
},
|
|
3578
|
+
quantity: 1,
|
|
3579
|
+
});
|
|
3580
|
+
return this;
|
|
3581
|
+
}
|
|
3582
|
+
// addTransfer(transfer: InternalTransfer): this {
|
|
3583
|
+
// this.transfers.push(transfer);
|
|
3584
|
+
// return this;
|
|
3585
|
+
// }
|
|
3586
|
+
// addExchange(info: {
|
|
3587
|
+
// debitAccountId: bigint,
|
|
3588
|
+
// debitCurrency: string,
|
|
3589
|
+
// credits: {
|
|
3590
|
+
// accountId: bigint,
|
|
3591
|
+
// amount: number,
|
|
3592
|
+
// }[],
|
|
3593
|
+
// creditCurrency: string,
|
|
3594
|
+
// pending?: boolean,
|
|
3595
|
+
// }): this {
|
|
3596
|
+
// const total = info.credits.reduce((acc, credit) => acc + credit.amount, 0);
|
|
3597
|
+
// this.transfers.push({
|
|
3598
|
+
// amount: total,
|
|
3599
|
+
// code: TransferCodes.exchange,
|
|
3600
|
+
// debitAccountId: info.debitAccountId,
|
|
3601
|
+
// currency: info.debitCurrency,
|
|
3602
|
+
// creditAccountId: getLiquidityAccount(info.debitCurrency),
|
|
3603
|
+
// pending: info.pending ?? this._pending,
|
|
3604
|
+
// });
|
|
3605
|
+
// for (let credit of info.credits) {
|
|
3606
|
+
// this.transfers.push({
|
|
3607
|
+
// amount: credit.amount,
|
|
3608
|
+
// code: TransferCodes.exchange,
|
|
3609
|
+
// debitAccountId: getLiquidityAccount(info.creditCurrency),
|
|
3610
|
+
// creditAccountId: credit.accountId,
|
|
3611
|
+
// currency: info.creditCurrency,
|
|
3612
|
+
// pending: info.pending ?? this._pending,
|
|
3613
|
+
// });
|
|
3614
|
+
// }
|
|
3615
|
+
// return this;
|
|
3616
|
+
// }
|
|
3617
|
+
addContract(info) {
|
|
3618
|
+
return this.addItem({
|
|
3619
|
+
amount: info.item.initialValue,
|
|
3620
|
+
code: TransferCodes.contract_payment,
|
|
3621
|
+
creditAccountId: info.contractAccountId,
|
|
3622
|
+
debitAccountId: info.debitAccountId,
|
|
3623
|
+
currency: CurrencyCodes.usd,
|
|
3624
|
+
pending: this._pending,
|
|
3625
|
+
name: 'Contract',
|
|
3626
|
+
description: info.item.description,
|
|
3627
|
+
metadata: {
|
|
3628
|
+
resourceKind: 'contract',
|
|
3629
|
+
recordName: info.recordName,
|
|
3630
|
+
address: info.item.address,
|
|
3631
|
+
},
|
|
3632
|
+
});
|
|
3633
|
+
}
|
|
3634
|
+
addContractApplicationFee(info) {
|
|
3635
|
+
if (info.fee > 0) {
|
|
3636
|
+
this.addItem({
|
|
3637
|
+
amount: info.fee,
|
|
3638
|
+
code: TransferCodes.xp_platform_fee,
|
|
3639
|
+
debitAccountId: info.debitAccountId,
|
|
3640
|
+
creditAccountId: ACCOUNT_IDS.revenue_xp_platform_fees,
|
|
3641
|
+
currency: CurrencyCodes.usd,
|
|
3642
|
+
pending: this._pending,
|
|
3643
|
+
name: 'Application Fee',
|
|
3644
|
+
// description: 'XP Platform Fee',
|
|
3645
|
+
metadata: {
|
|
3646
|
+
fee: true,
|
|
3647
|
+
resourceKind: 'contract',
|
|
3648
|
+
recordName: info.recordName,
|
|
3649
|
+
address: info.item.address,
|
|
3650
|
+
},
|
|
3651
|
+
});
|
|
3652
|
+
}
|
|
3653
|
+
return this;
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
856
3656
|
//# sourceMappingURL=SubscriptionController.js.map
|