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