payment-kit 1.24.4 → 1.25.0
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/api/src/index.ts +3 -0
- package/api/src/libs/credit-utils.ts +21 -0
- package/api/src/libs/discount/discount.ts +13 -0
- package/api/src/libs/env.ts +5 -0
- package/api/src/libs/error.ts +14 -0
- package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
- package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
- package/api/src/libs/exchange-rate/index.ts +5 -0
- package/api/src/libs/exchange-rate/service.ts +583 -0
- package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
- package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
- package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
- package/api/src/libs/exchange-rate/types.ts +114 -0
- package/api/src/libs/exchange-rate/validator.ts +319 -0
- package/api/src/libs/invoice-quote.ts +158 -0
- package/api/src/libs/invoice.ts +143 -7
- package/api/src/libs/math-utils.ts +46 -0
- package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
- package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
- package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
- package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
- package/api/src/libs/payment.ts +1 -1
- package/api/src/libs/price.ts +4 -1
- package/api/src/libs/queue/index.ts +8 -0
- package/api/src/libs/quote-service.ts +1132 -0
- package/api/src/libs/quote-validation.ts +388 -0
- package/api/src/libs/session.ts +686 -39
- package/api/src/libs/slippage.ts +135 -0
- package/api/src/libs/subscription.ts +185 -15
- package/api/src/libs/util.ts +64 -3
- package/api/src/locales/en.ts +50 -0
- package/api/src/locales/zh.ts +48 -0
- package/api/src/queues/auto-recharge.ts +295 -21
- package/api/src/queues/exchange-rate-health.ts +242 -0
- package/api/src/queues/invoice.ts +48 -1
- package/api/src/queues/notification.ts +167 -1
- package/api/src/queues/payment.ts +177 -7
- package/api/src/queues/subscription.ts +436 -6
- package/api/src/routes/auto-recharge-configs.ts +71 -6
- package/api/src/routes/checkout-sessions.ts +1730 -81
- package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
- package/api/src/routes/connect/change-payer.ts +2 -0
- package/api/src/routes/connect/change-payment.ts +61 -8
- package/api/src/routes/connect/change-plan.ts +161 -17
- package/api/src/routes/connect/collect.ts +9 -6
- package/api/src/routes/connect/delegation.ts +1 -0
- package/api/src/routes/connect/pay.ts +157 -0
- package/api/src/routes/connect/setup.ts +32 -10
- package/api/src/routes/connect/shared.ts +159 -13
- package/api/src/routes/connect/subscribe.ts +32 -9
- package/api/src/routes/credit-grants.ts +99 -0
- package/api/src/routes/exchange-rate-providers.ts +248 -0
- package/api/src/routes/exchange-rates.ts +87 -0
- package/api/src/routes/index.ts +4 -0
- package/api/src/routes/invoices.ts +280 -2
- package/api/src/routes/payment-links.ts +13 -0
- package/api/src/routes/prices.ts +84 -2
- package/api/src/routes/subscriptions.ts +526 -15
- package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
- package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
- package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
- package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
- package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
- package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
- package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
- package/api/src/store/models/auto-recharge-config.ts +12 -0
- package/api/src/store/models/checkout-session.ts +7 -0
- package/api/src/store/models/exchange-rate-provider.ts +225 -0
- package/api/src/store/models/index.ts +6 -0
- package/api/src/store/models/payment-intent.ts +6 -0
- package/api/src/store/models/price-quote.ts +284 -0
- package/api/src/store/models/price.ts +53 -5
- package/api/src/store/models/subscription.ts +11 -0
- package/api/src/store/models/types.ts +61 -1
- package/api/tests/libs/change-payment-plan.spec.ts +282 -0
- package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
- package/api/tests/libs/quote-service.spec.ts +199 -0
- package/api/tests/libs/session.spec.ts +464 -0
- package/api/tests/libs/slippage.spec.ts +109 -0
- package/api/tests/libs/token-data-provider.spec.ts +267 -0
- package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
- package/api/tests/models/price-dynamic.spec.ts +100 -0
- package/api/tests/models/price-quote.spec.ts +112 -0
- package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
- package/api/tests/routes/subscription-slippage.spec.ts +254 -0
- package/blocklet.yml +1 -1
- package/package.json +7 -6
- package/src/components/customer/credit-overview.tsx +14 -0
- package/src/components/discount/discount-info.tsx +8 -2
- package/src/components/invoice/list.tsx +146 -16
- package/src/components/invoice/table.tsx +276 -71
- package/src/components/invoice-pdf/template.tsx +3 -7
- package/src/components/metadata/form.tsx +6 -8
- package/src/components/price/form.tsx +519 -149
- package/src/components/promotion/active-redemptions.tsx +5 -3
- package/src/components/quote/info.tsx +234 -0
- package/src/hooks/subscription.ts +132 -2
- package/src/locales/en.tsx +145 -0
- package/src/locales/zh.tsx +143 -1
- package/src/pages/admin/billing/invoices/detail.tsx +41 -4
- package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
- package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
- package/src/pages/admin/products/index.tsx +12 -1
- package/src/pages/customer/invoice/detail.tsx +36 -12
- package/src/pages/customer/subscription/change-payment.tsx +65 -3
- package/src/pages/customer/subscription/change-plan.tsx +207 -38
- package/src/pages/customer/subscription/detail.tsx +599 -419
|
@@ -5,7 +5,7 @@ import Joi from 'joi';
|
|
|
5
5
|
import pick from 'lodash/pick';
|
|
6
6
|
import { Op } from 'sequelize';
|
|
7
7
|
|
|
8
|
-
import { BN } from '@ocap/util';
|
|
8
|
+
import { BN, fromUnitToToken } from '@ocap/util';
|
|
9
9
|
import { syncStripeInvoice } from '../integrations/stripe/handlers/invoice';
|
|
10
10
|
import { syncStripePayment } from '../integrations/stripe/handlers/payment-intent';
|
|
11
11
|
import { ensureStripeCustomer, ensureStripeSetupIntentForInvoicePayment } from '../integrations/stripe/resource';
|
|
@@ -33,11 +33,19 @@ import {
|
|
|
33
33
|
PromotionCode,
|
|
34
34
|
CreditGrant,
|
|
35
35
|
TaxRate,
|
|
36
|
+
PriceQuote,
|
|
36
37
|
} from '../store/models';
|
|
37
38
|
import { mergePaginate, defaultTimeOrderBy, getCachedOrFetch, DataSource } from '../libs/pagination';
|
|
38
39
|
import logger from '../libs/logger';
|
|
39
40
|
import { returnOverdraftProtectionQueue, returnStakeQueue } from '../queues/subscription';
|
|
40
41
|
import { checkRemainingStake } from '../libs/subscription';
|
|
42
|
+
import { getExchangeRateService } from '../libs/exchange-rate';
|
|
43
|
+
|
|
44
|
+
// Simple format amount helper for backend
|
|
45
|
+
function formatAmountForDisplay(amount: string, decimal: number, symbol: string): string {
|
|
46
|
+
const tokenValue = fromUnitToToken(amount, decimal);
|
|
47
|
+
return `${parseFloat(tokenValue).toFixed(Math.min(decimal, 6))} ${symbol}`;
|
|
48
|
+
}
|
|
41
49
|
|
|
42
50
|
const router = Router();
|
|
43
51
|
const authAdmin = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'] });
|
|
@@ -156,6 +164,7 @@ const schema = createListParamSchema<{
|
|
|
156
164
|
include_return_staking?: boolean;
|
|
157
165
|
include_overdraft_protection?: boolean;
|
|
158
166
|
include_recovered_from?: boolean;
|
|
167
|
+
include_quote?: boolean;
|
|
159
168
|
tax_rate_id?: string;
|
|
160
169
|
}>({
|
|
161
170
|
status: Joi.string().empty(''),
|
|
@@ -168,9 +177,64 @@ const schema = createListParamSchema<{
|
|
|
168
177
|
include_return_staking: Joi.boolean().empty(false),
|
|
169
178
|
include_overdraft_protection: Joi.boolean().default(true),
|
|
170
179
|
include_recovered_from: Joi.boolean().empty(false),
|
|
180
|
+
include_quote: Joi.boolean().empty(false),
|
|
171
181
|
tax_rate_id: Joi.string().empty(''),
|
|
172
182
|
});
|
|
173
183
|
|
|
184
|
+
const buildQuoteMetadata = (quote: PriceQuote) => {
|
|
185
|
+
const slippagePercent = quote.slippage_percent ?? (quote.metadata as any)?.slippage?.percent ?? null;
|
|
186
|
+
const minAcceptableRate = quote.min_acceptable_rate ?? (quote.metadata as any)?.slippage?.min_acceptable_rate ?? null;
|
|
187
|
+
const maxPayableToken = quote.max_payable_token ?? (quote.metadata as any)?.slippage?.max_payable_token ?? null;
|
|
188
|
+
const derivedAtMs = quote.slippage_derived_at_ms ?? (quote.metadata as any)?.slippage?.derived_at_ms ?? null;
|
|
189
|
+
const degraded = (quote.metadata as any)?.risk?.degraded ?? null;
|
|
190
|
+
const degradedReason = (quote.metadata as any)?.risk?.degraded_reason ?? null;
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
id: quote.id,
|
|
194
|
+
base_currency: quote.base_currency,
|
|
195
|
+
base_amount: quote.base_amount,
|
|
196
|
+
quoted_amount: quote.quoted_amount,
|
|
197
|
+
target_currency_id: quote.target_currency_id,
|
|
198
|
+
rate_currency_symbol: quote.rate_currency_symbol,
|
|
199
|
+
exchange_rate: quote.exchange_rate,
|
|
200
|
+
rate_provider_name: quote.rate_provider_name,
|
|
201
|
+
rate_provider_id: quote.rate_provider_id,
|
|
202
|
+
rate_timestamp_ms: quote.rate_timestamp_ms,
|
|
203
|
+
consensus_method: (quote.metadata as any)?.rate?.consensus_method || null,
|
|
204
|
+
providers: (quote.metadata as any)?.rate?.providers || [],
|
|
205
|
+
degraded,
|
|
206
|
+
degraded_reason: degradedReason,
|
|
207
|
+
slippage_percent: slippagePercent,
|
|
208
|
+
min_acceptable_rate: minAcceptableRate,
|
|
209
|
+
max_payable_token: maxPayableToken,
|
|
210
|
+
derived_at_ms: derivedAtMs,
|
|
211
|
+
status: quote.status,
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const attachQuoteMetadataToLines = (lines: any[] | undefined, quotesById: Map<string, PriceQuote>) => {
|
|
216
|
+
if (!lines?.length) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
lines.forEach((line) => {
|
|
220
|
+
const quoteId = line?.metadata?.quote_id;
|
|
221
|
+
if (!quoteId) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const quote = quotesById.get(quoteId);
|
|
225
|
+
if (!quote) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
line.metadata = {
|
|
229
|
+
...(line.metadata || {}),
|
|
230
|
+
quote: {
|
|
231
|
+
...(line.metadata?.quote || {}),
|
|
232
|
+
...buildQuoteMetadata(quote),
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
|
|
174
238
|
router.get('/', authMine, async (req, res) => {
|
|
175
239
|
try {
|
|
176
240
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
@@ -183,6 +247,7 @@ router.get('/', authMine, async (req, res) => {
|
|
|
183
247
|
include_staking,
|
|
184
248
|
include_return_staking,
|
|
185
249
|
include_overdraft_protection = true,
|
|
250
|
+
include_quote = false,
|
|
186
251
|
...query
|
|
187
252
|
} = await schema.validateAsync(req.query, {
|
|
188
253
|
stripUnknown: false,
|
|
@@ -191,6 +256,8 @@ router.get('/', authMine, async (req, res) => {
|
|
|
191
256
|
|
|
192
257
|
const taxRateId = query.tax_rate_id;
|
|
193
258
|
delete query.tax_rate_id;
|
|
259
|
+
delete (query as any).include_quote;
|
|
260
|
+
const includeQuote = include_quote === true;
|
|
194
261
|
|
|
195
262
|
const where = getWhereFromKvQuery(query.q);
|
|
196
263
|
|
|
@@ -275,7 +342,7 @@ router.get('/', authMine, async (req, res) => {
|
|
|
275
342
|
model: InvoiceItem,
|
|
276
343
|
as: 'lines',
|
|
277
344
|
where: { tax_rate_id: taxRateId },
|
|
278
|
-
attributes: [],
|
|
345
|
+
attributes: includeQuote ? ['id', 'metadata', 'amount', 'quantity', 'price_id'] : [],
|
|
279
346
|
required: true,
|
|
280
347
|
},
|
|
281
348
|
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
@@ -283,6 +350,28 @@ router.get('/', authMine, async (req, res) => {
|
|
|
283
350
|
{ model: Subscription, as: 'subscription', attributes: ['id', 'description'] },
|
|
284
351
|
{ model: Customer, as: 'customer' },
|
|
285
352
|
],
|
|
353
|
+
}).then(async (result) => {
|
|
354
|
+
if (!includeQuote) {
|
|
355
|
+
return result;
|
|
356
|
+
}
|
|
357
|
+
const quoteIds = new Set<string>();
|
|
358
|
+
result.forEach((invoice) => {
|
|
359
|
+
(invoice as any).lines?.forEach((line: any) => {
|
|
360
|
+
const quoteId = line?.metadata?.quote_id;
|
|
361
|
+
if (typeof quoteId === 'string' && quoteId.length > 0) {
|
|
362
|
+
quoteIds.add(quoteId);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
if (quoteIds.size === 0) {
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
const quotes = await PriceQuote.findAll({ where: { id: Array.from(quoteIds) } });
|
|
370
|
+
const quotesById = new Map(quotes.map((quote) => [quote.id, quote]));
|
|
371
|
+
result.forEach((invoice) => {
|
|
372
|
+
attachQuoteMetadataToLines((invoice as any).lines, quotesById);
|
|
373
|
+
});
|
|
374
|
+
return result;
|
|
286
375
|
}),
|
|
287
376
|
]);
|
|
288
377
|
|
|
@@ -308,6 +397,15 @@ router.get('/', authMine, async (req, res) => {
|
|
|
308
397
|
limit,
|
|
309
398
|
offset,
|
|
310
399
|
include: [
|
|
400
|
+
...(includeQuote
|
|
401
|
+
? [
|
|
402
|
+
{
|
|
403
|
+
model: InvoiceItem,
|
|
404
|
+
as: 'lines',
|
|
405
|
+
attributes: ['id', 'metadata', 'amount', 'quantity', 'price_id'],
|
|
406
|
+
},
|
|
407
|
+
]
|
|
408
|
+
: []),
|
|
311
409
|
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
312
410
|
{ model: PaymentMethod, as: 'paymentMethod' },
|
|
313
411
|
{ model: Subscription, as: 'subscription', attributes: ['id', 'description'] },
|
|
@@ -326,6 +424,24 @@ router.get('/', authMine, async (req, res) => {
|
|
|
326
424
|
{ model: Customer, as: 'customer' },
|
|
327
425
|
],
|
|
328
426
|
});
|
|
427
|
+
if (includeQuote && result.length > 0) {
|
|
428
|
+
const quoteIds = new Set<string>();
|
|
429
|
+
result.forEach((invoice) => {
|
|
430
|
+
(invoice as any).lines?.forEach((line: any) => {
|
|
431
|
+
const quoteId = line?.metadata?.quote_id;
|
|
432
|
+
if (typeof quoteId === 'string' && quoteId.length > 0) {
|
|
433
|
+
quoteIds.add(quoteId);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
if (quoteIds.size > 0) {
|
|
438
|
+
const quotes = await PriceQuote.findAll({ where: { id: Array.from(quoteIds) } });
|
|
439
|
+
const quotesById = new Map(quotes.map((quote) => [quote.id, quote]));
|
|
440
|
+
result.forEach((invoice) => {
|
|
441
|
+
attachQuoteMetadataToLines((invoice as any).lines, quotesById);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
329
445
|
return result;
|
|
330
446
|
},
|
|
331
447
|
meta: {
|
|
@@ -786,6 +902,42 @@ router.get('/:id', authPortal, async (req, res) => {
|
|
|
786
902
|
});
|
|
787
903
|
}
|
|
788
904
|
|
|
905
|
+
// Fetch related price quotes
|
|
906
|
+
let quotes: any[] = [];
|
|
907
|
+
try {
|
|
908
|
+
quotes = await PriceQuote.findAll({
|
|
909
|
+
where: {
|
|
910
|
+
invoice_id: doc.id,
|
|
911
|
+
},
|
|
912
|
+
order: [['created_at', 'ASC']],
|
|
913
|
+
});
|
|
914
|
+
const quoteIds = new Set<string>();
|
|
915
|
+
(json as any).lines?.forEach((line: any) => {
|
|
916
|
+
const quoteId = line?.metadata?.quote_id;
|
|
917
|
+
if (typeof quoteId === 'string' && quoteId.length > 0) {
|
|
918
|
+
quoteIds.add(quoteId);
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
if (quoteIds.size > 0) {
|
|
922
|
+
const quotesById = new Map(quotes.map((quote) => [quote.id, quote]));
|
|
923
|
+
const missingIds = Array.from(quoteIds).filter((id) => !quotesById.has(id));
|
|
924
|
+
if (missingIds.length > 0) {
|
|
925
|
+
const quotesByLine = await PriceQuote.findAll({ where: { id: missingIds } });
|
|
926
|
+
quotesByLine.forEach((quote) => quotesById.set(quote.id, quote));
|
|
927
|
+
}
|
|
928
|
+
quotes = Array.from(quotesById.values());
|
|
929
|
+
attachQuoteMetadataToLines((json as any).lines, quotesById);
|
|
930
|
+
} else if (quotes.length > 0) {
|
|
931
|
+
const quotesById = new Map(quotes.map((quote) => [quote.id, quote]));
|
|
932
|
+
attachQuoteMetadataToLines((json as any).lines, quotesById);
|
|
933
|
+
}
|
|
934
|
+
} catch (error) {
|
|
935
|
+
logger.error('Failed to fetch related price quotes', {
|
|
936
|
+
error,
|
|
937
|
+
invoiceId: doc.id,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
789
941
|
if (doc.metadata?.invoice_id || doc.metadata?.prev_invoice_id) {
|
|
790
942
|
const relatedInvoice = await Invoice.findByPk(doc.metadata.invoice_id || doc.metadata.prev_invoice_id, {
|
|
791
943
|
attributes: ['id', 'number', 'status', 'billing_reason'],
|
|
@@ -797,6 +949,7 @@ router.get('/:id', authPortal, async (req, res) => {
|
|
|
797
949
|
relatedCreditGrants,
|
|
798
950
|
paymentLink,
|
|
799
951
|
checkoutSession,
|
|
952
|
+
quotes,
|
|
800
953
|
});
|
|
801
954
|
}
|
|
802
955
|
return res.json({
|
|
@@ -805,6 +958,7 @@ router.get('/:id', authPortal, async (req, res) => {
|
|
|
805
958
|
relatedCreditGrants,
|
|
806
959
|
paymentLink,
|
|
807
960
|
checkoutSession,
|
|
961
|
+
quotes,
|
|
808
962
|
});
|
|
809
963
|
}
|
|
810
964
|
return res.status(404).json(null);
|
|
@@ -814,6 +968,129 @@ router.get('/:id', authPortal, async (req, res) => {
|
|
|
814
968
|
}
|
|
815
969
|
});
|
|
816
970
|
|
|
971
|
+
/**
|
|
972
|
+
* Get available payment options for an invoice with estimated prices
|
|
973
|
+
* Used when user wants to switch payment method after price change
|
|
974
|
+
*/
|
|
975
|
+
router.get('/:id/payment-options', authPortal, async (req, res) => {
|
|
976
|
+
try {
|
|
977
|
+
const invoice = await Invoice.findByPk(req.params.id, {
|
|
978
|
+
include: [
|
|
979
|
+
{ model: InvoiceItem, as: 'lines' },
|
|
980
|
+
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
981
|
+
],
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
if (!invoice) {
|
|
985
|
+
return res.status(404).json({ error: 'Invoice not found' });
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Get all available currencies for this invoice's products
|
|
989
|
+
const invoiceItems = (invoice as any).lines || [];
|
|
990
|
+
const priceIds = invoiceItems.map((item: any) => item.price_id).filter(Boolean);
|
|
991
|
+
|
|
992
|
+
// Get all unique product IDs from prices
|
|
993
|
+
const prices = await Price.findAll({
|
|
994
|
+
where: { id: priceIds },
|
|
995
|
+
include: [{ model: PaymentCurrency, as: 'currencies' }],
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
// Collect all unique currencies
|
|
999
|
+
const currencyMap = new Map<string, any>();
|
|
1000
|
+
const currentCurrencyId = invoice.currency_id;
|
|
1001
|
+
|
|
1002
|
+
// Add current currency first
|
|
1003
|
+
const { paymentCurrency } = invoice as any;
|
|
1004
|
+
if (paymentCurrency) {
|
|
1005
|
+
const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
|
|
1006
|
+
currencyMap.set(paymentCurrency.id, {
|
|
1007
|
+
...paymentCurrency.toJSON(),
|
|
1008
|
+
method: paymentMethod?.toJSON(),
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Add other available currencies from prices
|
|
1013
|
+
for (const price of prices) {
|
|
1014
|
+
const priceCurrencies = (price as any).currencies || [];
|
|
1015
|
+
for (const currency of priceCurrencies) {
|
|
1016
|
+
if (!currencyMap.has(currency.id)) {
|
|
1017
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1018
|
+
const paymentMethod = await PaymentMethod.findByPk(currency.payment_method_id);
|
|
1019
|
+
currencyMap.set(currency.id, {
|
|
1020
|
+
...currency.toJSON(),
|
|
1021
|
+
method: paymentMethod?.toJSON(),
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Calculate estimated amounts for each currency
|
|
1028
|
+
const exchangeRateService = getExchangeRateService();
|
|
1029
|
+
const options = [];
|
|
1030
|
+
|
|
1031
|
+
for (const [currencyId, currency] of currencyMap) {
|
|
1032
|
+
let estimatedAmount = '';
|
|
1033
|
+
const isCurrentMethod = currencyId === currentCurrencyId;
|
|
1034
|
+
|
|
1035
|
+
// For current method, use the invoice amount
|
|
1036
|
+
if (isCurrentMethod) {
|
|
1037
|
+
estimatedAmount = formatAmountForDisplay(invoice.amount_due, currency.decimal, currency.symbol);
|
|
1038
|
+
} else {
|
|
1039
|
+
// For other methods, calculate based on USD base amount and current exchange rate
|
|
1040
|
+
try {
|
|
1041
|
+
// Get USD total from line items
|
|
1042
|
+
let usdTotal = 0;
|
|
1043
|
+
for (const item of invoiceItems) {
|
|
1044
|
+
const itemPrice = prices.find((p) => p.id === item.price_id);
|
|
1045
|
+
if (itemPrice?.base_amount) {
|
|
1046
|
+
usdTotal += parseFloat(itemPrice.base_amount) * item.quantity;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
if (usdTotal > 0) {
|
|
1051
|
+
// Get exchange rate for this currency
|
|
1052
|
+
const rateSymbol =
|
|
1053
|
+
currency.method?.type === 'arcblock' ? 'ABT' : `${currency.symbol}@${currency.method?.type}`;
|
|
1054
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1055
|
+
const rateResult = await exchangeRateService.getRate(rateSymbol);
|
|
1056
|
+
const rate = parseFloat(rateResult.rate);
|
|
1057
|
+
|
|
1058
|
+
if (rate > 0) {
|
|
1059
|
+
const tokenAmount = usdTotal / rate;
|
|
1060
|
+
const tokenAmountInUnits = Math.floor(tokenAmount * 10 ** currency.decimal);
|
|
1061
|
+
estimatedAmount = `≈ ${formatAmountForDisplay(tokenAmountInUnits.toString(), currency.decimal, currency.symbol)}`;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
logger.warn('Failed to calculate estimated amount for currency', {
|
|
1066
|
+
currencyId,
|
|
1067
|
+
error: (error as Error).message,
|
|
1068
|
+
});
|
|
1069
|
+
estimatedAmount = '—';
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
options.push({
|
|
1074
|
+
currency,
|
|
1075
|
+
estimatedAmount,
|
|
1076
|
+
isCurrentMethod,
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Sort: current method first, then by symbol
|
|
1081
|
+
options.sort((a, b) => {
|
|
1082
|
+
if (a.isCurrentMethod) return -1;
|
|
1083
|
+
if (b.isCurrentMethod) return 1;
|
|
1084
|
+
return a.currency.symbol.localeCompare(b.currency.symbol);
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
return res.json({ options });
|
|
1088
|
+
} catch (err: any) {
|
|
1089
|
+
logger.error('Failed to get payment options for invoice', { error: err, invoiceId: req.params.id });
|
|
1090
|
+
return res.status(500).json({ error: `Failed to get payment options: ${err.message}` });
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
|
|
817
1094
|
router.post('/pay-stripe', authPortal, async (req, res) => {
|
|
818
1095
|
try {
|
|
819
1096
|
const { invoice_ids, subscription_id, customer_id, currency_id } = req.body;
|
|
@@ -1021,4 +1298,5 @@ router.post('/:id/void', authAdmin, async (req, res) => {
|
|
|
1021
1298
|
return res.status(400).json({ error: 'Failed to void invoice' });
|
|
1022
1299
|
}
|
|
1023
1300
|
});
|
|
1301
|
+
|
|
1024
1302
|
export default router;
|
|
@@ -138,6 +138,19 @@ export async function createPaymentLink(payload: any) {
|
|
|
138
138
|
throw new Error('Multiple items with custom unit amount are not supported in payment link');
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
// Check for mixed pricing types (fixed + dynamic)
|
|
142
|
+
const hasDynamicPricing = items.some((item) => {
|
|
143
|
+
return item.price?.pricing_type === 'dynamic';
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const hasFixedPricing = items.some((item) => {
|
|
147
|
+
return !item.price?.pricing_type || item.price?.pricing_type === 'fixed';
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (hasDynamicPricing && hasFixedPricing) {
|
|
151
|
+
throw new Error('Cannot mix fixed pricing and dynamic pricing in the same payment link');
|
|
152
|
+
}
|
|
153
|
+
|
|
141
154
|
for (let i = 0; i < items.length; i++) {
|
|
142
155
|
const result = isLineItemAligned(items, i);
|
|
143
156
|
if (result.currency === false) {
|
package/api/src/routes/prices.ts
CHANGED
|
@@ -5,6 +5,8 @@ import pick from 'lodash/pick';
|
|
|
5
5
|
import type { WhereOptions } from 'sequelize';
|
|
6
6
|
|
|
7
7
|
import { createListParamSchema, getOrder, getWhereFromQuery, MetadataSchema } from '../libs/api';
|
|
8
|
+
import { getExchangeRateService } from '../libs/exchange-rate';
|
|
9
|
+
import { getExchangeRateSymbol, hasTokenAddress } from '../libs/exchange-rate/token-address-mapping';
|
|
8
10
|
import logger from '../libs/logger';
|
|
9
11
|
import { authenticate } from '../libs/security';
|
|
10
12
|
import { canUpsell } from '../libs/session';
|
|
@@ -12,7 +14,7 @@ import { PaymentCurrency } from '../store/models/payment-currency';
|
|
|
12
14
|
import { Price } from '../store/models/price';
|
|
13
15
|
import { Product } from '../store/models/product';
|
|
14
16
|
import { checkCurrencySupportRecurring } from '../libs/product';
|
|
15
|
-
import { Meter } from '../store/models';
|
|
17
|
+
import { ChainType, Meter, PaymentMethod } from '../store/models';
|
|
16
18
|
|
|
17
19
|
const router = Router();
|
|
18
20
|
|
|
@@ -66,6 +68,41 @@ const CreditConfigSchema = Joi.object({
|
|
|
66
68
|
'any.invalid': 'valid_duration_* is mutually exclusive with schedule.expire_with_next_grant',
|
|
67
69
|
});
|
|
68
70
|
|
|
71
|
+
const exchangeRateService = getExchangeRateService();
|
|
72
|
+
|
|
73
|
+
async function validateDynamicPricingCurrencies(currencyIds: string[]): Promise<string | null> {
|
|
74
|
+
if (!currencyIds?.length) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const uniqueIds = Array.from(new Set(currencyIds.filter(Boolean)));
|
|
79
|
+
const currencies = (await PaymentCurrency.findAll({
|
|
80
|
+
where: { id: uniqueIds },
|
|
81
|
+
include: [{ model: PaymentMethod, as: 'payment_method' }],
|
|
82
|
+
})) as (PaymentCurrency & { payment_method: PaymentMethod })[];
|
|
83
|
+
const unsupported: string[] = [];
|
|
84
|
+
|
|
85
|
+
await Promise.all(
|
|
86
|
+
currencies.map(async (currency) => {
|
|
87
|
+
const rateSymbol = getExchangeRateSymbol(currency.symbol, currency.payment_method?.type as ChainType);
|
|
88
|
+
if (currency.payment_method.type === 'stripe') {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!hasTokenAddress(rateSymbol)) {
|
|
92
|
+
unsupported.push(`${currency.symbol}: missing token mapping`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
await exchangeRateService.getRate(rateSymbol);
|
|
97
|
+
} catch (error: any) {
|
|
98
|
+
unsupported.push(`${currency.symbol}: ${error.message}`);
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return unsupported.length ? `Exchange rate not available for ${unsupported.join(', ')}` : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
69
106
|
export async function getExpandedPrice(id: string) {
|
|
70
107
|
const price = await Price.findByPkOrLookupKey(id, {
|
|
71
108
|
include: [
|
|
@@ -285,6 +322,18 @@ router.post('/', auth, async (req, res) => {
|
|
|
285
322
|
return res.status(400).json({ error: `metadata invalid: ${metadataError.message}` });
|
|
286
323
|
}
|
|
287
324
|
}
|
|
325
|
+
|
|
326
|
+
if (req.body.pricing_type === 'dynamic') {
|
|
327
|
+
const currencyIds = req.body.currency_options?.map((x: any) => x.currency_id).filter(Boolean) || [];
|
|
328
|
+
if (req.body.currency_id) {
|
|
329
|
+
currencyIds.push(req.body.currency_id);
|
|
330
|
+
}
|
|
331
|
+
const validationError = await validateDynamicPricingCurrencies(currencyIds);
|
|
332
|
+
if (validationError) {
|
|
333
|
+
return res.status(400).json({ error: validationError });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
288
337
|
const result = await createPrice({
|
|
289
338
|
...req.body,
|
|
290
339
|
livemode: !!req.livemode,
|
|
@@ -388,7 +437,28 @@ router.put('/:id', auth, async (req, res) => {
|
|
|
388
437
|
req.body,
|
|
389
438
|
locked
|
|
390
439
|
? ['nickname', 'description', 'metadata', 'upsell', 'lookup_key', ...quantityKeys]
|
|
391
|
-
: [
|
|
440
|
+
: [
|
|
441
|
+
'type',
|
|
442
|
+
'model',
|
|
443
|
+
'active',
|
|
444
|
+
'livemode',
|
|
445
|
+
'nickname',
|
|
446
|
+
'recurring',
|
|
447
|
+
'description',
|
|
448
|
+
'tiers',
|
|
449
|
+
'unit_amount',
|
|
450
|
+
'transform_quantity',
|
|
451
|
+
'metadata',
|
|
452
|
+
'currency_id',
|
|
453
|
+
'lookup_key',
|
|
454
|
+
'currency_options',
|
|
455
|
+
'upsell',
|
|
456
|
+
'pricing_type',
|
|
457
|
+
'base_currency',
|
|
458
|
+
'base_amount',
|
|
459
|
+
'dynamic_pricing_config',
|
|
460
|
+
...quantityKeys,
|
|
461
|
+
] // prettier-ignore
|
|
392
462
|
)
|
|
393
463
|
);
|
|
394
464
|
|
|
@@ -454,6 +524,18 @@ router.put('/:id', auth, async (req, res) => {
|
|
|
454
524
|
.json({ error: `currency ${notSupportCurrencies.map((x) => x.name).join(', ')} does not support recurring` });
|
|
455
525
|
}
|
|
456
526
|
|
|
527
|
+
const pricingType = updates.pricing_type || doc.pricing_type;
|
|
528
|
+
if (pricingType === 'dynamic') {
|
|
529
|
+
const currencyOptions = updates.currency_options || doc.currency_options || [];
|
|
530
|
+
const currencyIds = currencyOptions.map((x: any) => x.currency_id).filter(Boolean);
|
|
531
|
+
const validationError = await validateDynamicPricingCurrencies(
|
|
532
|
+
currencyIds.length ? currencyIds : [updates.currency_id || doc.currency_id]
|
|
533
|
+
);
|
|
534
|
+
if (validationError) {
|
|
535
|
+
return res.status(400).json({ error: validationError });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
457
539
|
try {
|
|
458
540
|
await doc.update(Price.formatBeforeSave(updates));
|
|
459
541
|
logger.info(`Price updated: ${req.params.id}`, { priceId: req.params.id, updates, requestedBy: req.user?.did });
|