payment-kit 1.21.13 → 1.21.15

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.
Files changed (56) hide show
  1. package/api/src/crons/payment-stat.ts +31 -23
  2. package/api/src/libs/invoice.ts +29 -4
  3. package/api/src/libs/product.ts +28 -4
  4. package/api/src/routes/checkout-sessions.ts +46 -1
  5. package/api/src/routes/index.ts +2 -0
  6. package/api/src/routes/invoices.ts +63 -2
  7. package/api/src/routes/payment-stats.ts +244 -22
  8. package/api/src/routes/products.ts +3 -0
  9. package/api/src/routes/tax-rates.ts +220 -0
  10. package/api/src/store/migrations/20251001-add-tax-code-to-products.ts +20 -0
  11. package/api/src/store/migrations/20251001-create-tax-rates.ts +17 -0
  12. package/api/src/store/migrations/20251007-relate-tax-rate-to-invoice.ts +24 -0
  13. package/api/src/store/migrations/20251009-add-tax-behavior.ts +21 -0
  14. package/api/src/store/models/index.ts +3 -0
  15. package/api/src/store/models/invoice-item.ts +10 -0
  16. package/api/src/store/models/price.ts +7 -0
  17. package/api/src/store/models/product.ts +7 -0
  18. package/api/src/store/models/tax-rate.ts +352 -0
  19. package/api/tests/models/tax-rate.spec.ts +777 -0
  20. package/blocklet.yml +2 -2
  21. package/package.json +6 -6
  22. package/public/currencies/dollar.png +0 -0
  23. package/src/components/collapse.tsx +3 -2
  24. package/src/components/drawer-form.tsx +2 -1
  25. package/src/components/invoice/list.tsx +38 -3
  26. package/src/components/invoice/table.tsx +48 -2
  27. package/src/components/metadata/form.tsx +2 -2
  28. package/src/components/payment-intent/list.tsx +19 -3
  29. package/src/components/payouts/list.tsx +19 -3
  30. package/src/components/price/currency-select.tsx +105 -48
  31. package/src/components/price/form.tsx +3 -1
  32. package/src/components/product/form.tsx +79 -5
  33. package/src/components/refund/list.tsx +20 -3
  34. package/src/components/subscription/items/actions.tsx +25 -15
  35. package/src/components/subscription/list.tsx +15 -3
  36. package/src/components/tax/actions.tsx +140 -0
  37. package/src/components/tax/filter-toolbar.tsx +230 -0
  38. package/src/components/tax/tax-code-select.tsx +633 -0
  39. package/src/components/tax/tax-rate-form.tsx +177 -0
  40. package/src/components/tax/tax-utils.ts +38 -0
  41. package/src/components/tax/taxCodes.json +10882 -0
  42. package/src/components/uploader.tsx +3 -0
  43. package/src/hooks/cache-state.ts +84 -0
  44. package/src/locales/en.tsx +152 -0
  45. package/src/locales/zh.tsx +149 -0
  46. package/src/pages/admin/billing/invoices/detail.tsx +1 -1
  47. package/src/pages/admin/index.tsx +2 -0
  48. package/src/pages/admin/overview.tsx +1114 -322
  49. package/src/pages/admin/products/vendors/index.tsx +4 -2
  50. package/src/pages/admin/tax/create.tsx +104 -0
  51. package/src/pages/admin/tax/detail.tsx +476 -0
  52. package/src/pages/admin/tax/edit.tsx +126 -0
  53. package/src/pages/admin/tax/index.tsx +86 -0
  54. package/src/pages/admin/tax/list.tsx +334 -0
  55. package/src/pages/customer/subscription/change-payment.tsx +1 -1
  56. package/src/pages/home.tsx +6 -3
@@ -125,29 +125,37 @@ export async function createPaymentStat(date?: string) {
125
125
  // eslint-disable-next-line @typescript-eslint/no-shadow
126
126
  dates.map(async (date) => {
127
127
  const { stats, timestamp, currencies } = await getPaymentStat(date);
128
- await Promise.all(
129
- currencies.map(async (currency) => {
130
- const exist = await PaymentStat.findOne({ where: { timestamp, currency_id: currency.id } });
131
- if (exist) {
132
- await exist.update({
133
- amount_paid: stats.payment![currency.id] || '0',
134
- amount_payout: stats.payout![currency.id] || '0',
135
- amount_refund: stats.refund![currency.id] || '0',
136
- });
137
- logger.info('PaymentStat updated', { date, timestamp, currency: currency.symbol });
138
- } else {
139
- await PaymentStat.create({
140
- livemode: currency.livemode,
141
- timestamp,
142
- currency_id: currency.id,
143
- amount_paid: stats.payment![currency.id] || '0',
144
- amount_payout: stats.payout![currency.id] || '0',
145
- amount_refund: stats.refund![currency.id] || '0',
146
- });
147
- logger.info('PaymentStat created', { date, timestamp, currency: currency.symbol });
148
- }
149
- })
150
- );
128
+ const currencyIds = currencies.map((currency) => currency.id);
129
+ const existingStats = await PaymentStat.findAll({
130
+ where: {
131
+ timestamp,
132
+ currency_id: { [Op.in]: currencyIds },
133
+ },
134
+ attributes: ['id', 'currency_id'],
135
+ });
136
+ const existingMap = existingStats.reduce<Record<string, string>>((acc, item) => {
137
+ acc[item.currency_id] = item.id;
138
+ return acc;
139
+ }, {});
140
+ const records = currencies.map((currency) => {
141
+ const base = {
142
+ livemode: currency.livemode,
143
+ timestamp,
144
+ currency_id: currency.id,
145
+ amount_paid: stats.payment?.[currency.id] || '0',
146
+ amount_payout: stats.payout?.[currency.id] || '0',
147
+ amount_refund: stats.refund?.[currency.id] || '0',
148
+ } as any;
149
+ const existingId = existingMap[currency.id];
150
+ if (existingId) {
151
+ base.id = existingId;
152
+ }
153
+ return base;
154
+ });
155
+ await PaymentStat.bulkCreate(records, {
156
+ updateOnDuplicate: ['amount_paid', 'amount_payout', 'amount_refund'],
157
+ });
158
+ logger.info('PaymentStat synced', { date, timestamp, count: records.length });
151
159
  })
152
160
  );
153
161
  }
@@ -20,6 +20,7 @@ import {
20
20
  SimpleCustomField,
21
21
  Subscription,
22
22
  SubscriptionItem,
23
+ TaxRate,
23
24
  TInvoice,
24
25
  TLineItemExpanded,
25
26
  UsageRecord,
@@ -520,8 +521,31 @@ async function createInvoiceWithItems(props: BaseInvoiceProps): Promise<{
520
521
  }
521
522
  // create invoice items
522
523
  const items = await Promise.all(
523
- itemsData.map((item) =>
524
- InvoiceItem.create({
524
+ itemsData.map(async (item) => {
525
+ // Match tax rate for this specific item
526
+ let taxRateId: string | undefined;
527
+ if (customer.address?.country && item.price_id) {
528
+ try {
529
+ const price = await Price.findByPk(item.price_id);
530
+ if (price?.product_id) {
531
+ const product = await Product.findByPk(price.product_id);
532
+ if (product) {
533
+ const taxRate = await TaxRate.findMatchingRate({
534
+ country: customer.address.country,
535
+ state: customer.address.state,
536
+ postalCode: customer.address.postal_code,
537
+ taxCode: product.tax_code,
538
+ livemode,
539
+ });
540
+ taxRateId = taxRate?.id;
541
+ }
542
+ }
543
+ } catch (error) {
544
+ logger.error('Failed to match tax rate for invoice item:', error);
545
+ }
546
+ }
547
+
548
+ return InvoiceItem.create({
525
549
  livemode,
526
550
  amount: item.amount,
527
551
  quantity: item.quantity,
@@ -533,6 +557,7 @@ async function createInvoiceWithItems(props: BaseInvoiceProps): Promise<{
533
557
  invoice_id: invoice.id,
534
558
  subscription_id: subscription?.id,
535
559
  subscription_item_id: item.subscription_item_id,
560
+ tax_rate_id: taxRateId,
536
561
  discountable: item.discountable || false,
537
562
  discounts: [], // Keep as empty for now - this is legacy field
538
563
  discount_amounts:
@@ -541,8 +566,8 @@ async function createInvoiceWithItems(props: BaseInvoiceProps): Promise<{
541
566
  proration: false,
542
567
  proration_details: {},
543
568
  metadata: item.metadata || {},
544
- })
545
- )
569
+ });
570
+ })
546
571
  );
547
572
 
548
573
  return { invoice, items };
@@ -3,6 +3,7 @@ import isEmpty from 'lodash/isEmpty';
3
3
  import { Op } from 'sequelize';
4
4
  import { CheckoutSession, PaymentCurrency, PaymentMethod, Price, Product, Subscription } from '../store/models';
5
5
  import { EVM_CHAIN_TYPES } from './constants';
6
+ import { getApproveFunction } from '../integrations/ethereum/contract';
6
7
 
7
8
  export async function getMainProductName(subscriptionId: string): Promise<string> {
8
9
  const subscription = await Subscription.findByPk(subscriptionId);
@@ -74,11 +75,34 @@ export async function checkCurrencySupportRecurring(currencyIds: string[] | stri
74
75
  where: { id: { [Op.in]: currencyIdsArray } },
75
76
  include: [{ model: PaymentMethod, as: 'payment_method' }],
76
77
  })) as (PaymentCurrency & { payment_method: PaymentMethod })[];
77
- const notSupportCurrencies = currencies.filter(
78
- (c) => EVM_CHAIN_TYPES.includes(c.payment_method?.type) && c.payment_method?.default_currency_id === c.id
79
- );
78
+
79
+ const notSupportCurrencies = (
80
+ await Promise.all(
81
+ currencies.map(async (currency) => {
82
+ const paymentMethod = currency.payment_method;
83
+ if (!paymentMethod) return null;
84
+
85
+ if (EVM_CHAIN_TYPES.includes(paymentMethod.type) && paymentMethod.default_currency_id === currency.id) {
86
+ return currency;
87
+ }
88
+
89
+ if (['ethereum', 'base'].includes(paymentMethod.type) && currency.contract) {
90
+ try {
91
+ const provider = paymentMethod.getEvmClient();
92
+ await getApproveFunction(provider, currency.contract);
93
+ return null;
94
+ } catch (err) {
95
+ return currency;
96
+ }
97
+ }
98
+
99
+ return null;
100
+ })
101
+ )
102
+ ).filter((c) => c !== null);
103
+
80
104
  return {
81
105
  notSupportCurrencies,
82
- validate: notSupportCurrencies?.length === 0,
106
+ validate: notSupportCurrencies.length === 0,
83
107
  };
84
108
  }
@@ -108,6 +108,7 @@ import {
108
108
  import { rollbackDiscountUsageForCheckoutSession, applyDiscountsToLineItems } from '../libs/discount/discount';
109
109
  import { formatToShortUrl } from '../libs/url';
110
110
  import { destroyExistingInvoice } from '../libs/invoice';
111
+ import { getApproveFunction } from '../integrations/ethereum/contract';
111
112
 
112
113
  const router = Router();
113
114
 
@@ -120,7 +121,51 @@ const getPaymentMethods = async (doc: CheckoutSession) => {
120
121
  const paymentMethods = await PaymentMethod.expand(doc.livemode, { type: doc.payment_method_types });
121
122
  const supportedCurrencies = getSupportedPaymentCurrencies(doc.line_items as any[]);
122
123
  const methods = getSupportedPaymentMethods(paymentMethods as any[], (x) => supportedCurrencies.includes(x.id));
123
- return sortBy(methods, (m) => (m.payment_currencies.some((c) => c.is_base_currency) ? 0 : 1));
124
+
125
+ const needsApprove = ['subscription', 'setup'].includes(doc.mode);
126
+ if (!needsApprove) {
127
+ return sortBy(methods, (m) => (m.payment_currencies.some((c) => c.is_base_currency) ? 0 : 1));
128
+ }
129
+
130
+ const filteredMethods = await Promise.all(
131
+ methods.map(async (method) => {
132
+ if (!['ethereum', 'base'].includes(method.type)) {
133
+ return method;
134
+ }
135
+
136
+ const filteredCurrencies = await Promise.all(
137
+ method.payment_currencies.map(async (currency) => {
138
+ if (!currency.contract) {
139
+ return currency;
140
+ }
141
+
142
+ try {
143
+ const paymentMethodInstance = await PaymentMethod.findByPk(method.id);
144
+ if (!paymentMethodInstance) {
145
+ return null;
146
+ }
147
+ const provider = paymentMethodInstance.getEvmClient();
148
+ await getApproveFunction(provider, currency.contract);
149
+ return currency;
150
+ } catch (err: any) {
151
+ logger.warn(`Currency ${currency.id} (${currency.symbol}) does not support approve function`, {
152
+ contract: currency.contract,
153
+ error: err.message,
154
+ });
155
+ return null;
156
+ }
157
+ })
158
+ );
159
+
160
+ return {
161
+ ...method,
162
+ payment_currencies: filteredCurrencies.filter((c) => c !== null),
163
+ };
164
+ })
165
+ );
166
+
167
+ const validMethods = filteredMethods.filter((m) => m.payment_currencies.length > 0);
168
+ return sortBy(validMethods, (m) => (m.payment_currencies.some((c) => c.is_base_currency) ? 0 : 1));
124
169
  };
125
170
 
126
171
  const getPaymentTypes = async (items: any[]) => {
@@ -12,6 +12,7 @@ import events from './events';
12
12
  import stripe from './integrations/stripe';
13
13
  import invoices from './invoices';
14
14
  import meterEvents from './meter-events';
15
+ import taxRates from './tax-rates';
15
16
  import meters from './meters';
16
17
  import passports from './passports';
17
18
  import paymentCurrencies from './payment-currencies';
@@ -75,6 +76,7 @@ router.use('/payment-currencies', paymentCurrencies);
75
76
  router.use('/payment-stats', paymentStats);
76
77
  router.use('/prices', prices);
77
78
  router.use('/pricing-tables', pricingTables);
79
+ router.use('/tax-rates', taxRates);
78
80
  router.use('/products', products);
79
81
  router.use('/promotion-codes', promotionCodes);
80
82
  router.use('/payouts', payouts);
@@ -31,6 +31,7 @@ import {
31
31
  Coupon,
32
32
  PromotionCode,
33
33
  CreditGrant,
34
+ TaxRate,
34
35
  } from '../store/models';
35
36
  import { mergePaginate, defaultTimeOrderBy, getCachedOrFetch, DataSource } from '../libs/pagination';
36
37
  import logger from '../libs/logger';
@@ -154,6 +155,7 @@ const schema = createListParamSchema<{
154
155
  include_return_staking?: boolean;
155
156
  include_overdraft_protection?: boolean;
156
157
  include_recovered_from?: boolean;
158
+ tax_rate_id?: string;
157
159
  }>({
158
160
  status: Joi.string().empty(''),
159
161
  customer_id: Joi.string().empty(''),
@@ -165,6 +167,7 @@ const schema = createListParamSchema<{
165
167
  include_return_staking: Joi.boolean().empty(false),
166
168
  include_overdraft_protection: Joi.boolean().default(true),
167
169
  include_recovered_from: Joi.boolean().empty(false),
170
+ tax_rate_id: Joi.string().empty(''),
168
171
  });
169
172
 
170
173
  router.get('/', authMine, async (req, res) => {
@@ -185,6 +188,9 @@ router.get('/', authMine, async (req, res) => {
185
188
  allowUnknown: true,
186
189
  });
187
190
 
191
+ const taxRateId = query.tax_rate_id;
192
+ delete query.tax_rate_id;
193
+
188
194
  const where = getWhereFromKvQuery(query.q);
189
195
 
190
196
  if (status) {
@@ -242,7 +248,51 @@ router.get('/', authMine, async (req, res) => {
242
248
  where.billing_reason = { [Op.notIn]: excludeBillingReasons };
243
249
  }
244
250
 
245
- // Build data sources with proper metadata
251
+ if (taxRateId) {
252
+ const [count, list] = await Promise.all([
253
+ Invoice.count({
254
+ where,
255
+ include: [
256
+ {
257
+ model: InvoiceItem,
258
+ as: 'lines',
259
+ where: { tax_rate_id: taxRateId },
260
+ attributes: [],
261
+ required: true,
262
+ },
263
+ ],
264
+ distinct: true,
265
+ }),
266
+ Invoice.findAll({
267
+ where,
268
+ order: getOrder(req.query, [['created_at', query.o === 'asc' ? 'ASC' : 'DESC']]),
269
+ limit: pageSize,
270
+ offset: (page - 1) * pageSize,
271
+ subQuery: false,
272
+ include: [
273
+ {
274
+ model: InvoiceItem,
275
+ as: 'lines',
276
+ where: { tax_rate_id: taxRateId },
277
+ attributes: [],
278
+ required: true,
279
+ },
280
+ { model: PaymentCurrency, as: 'paymentCurrency' },
281
+ { model: PaymentMethod, as: 'paymentMethod' },
282
+ { model: Subscription, as: 'subscription', attributes: ['id', 'description'] },
283
+ { model: Customer, as: 'customer' },
284
+ ],
285
+ }),
286
+ ]);
287
+
288
+ res.json({
289
+ count,
290
+ list,
291
+ paging: { page, pageSize },
292
+ });
293
+ return;
294
+ }
295
+
246
296
  const sources: DataSource<Invoice>[] = [];
247
297
 
248
298
  // Primary data source: Invoice database records
@@ -595,7 +645,18 @@ router.get('/:id', authPortal, async (req, res) => {
595
645
  { model: PaymentMethod, as: 'paymentMethod' },
596
646
  { model: PaymentIntent, as: 'paymentIntent' },
597
647
  { model: Subscription, as: 'subscription' },
598
- { model: InvoiceItem, as: 'lines' },
648
+ {
649
+ model: InvoiceItem,
650
+ as: 'lines',
651
+ include: [
652
+ {
653
+ model: TaxRate,
654
+ as: 'tax_rate',
655
+ attributes: ['id', 'display_name', 'percentage', 'country', 'state', 'postal_code'],
656
+ required: false,
657
+ },
658
+ ],
659
+ },
599
660
  { model: Customer, as: 'customer' },
600
661
  ],
601
662
  })) as TInvoiceExpanded | null;
@@ -3,6 +3,7 @@ import Joi from 'joi';
3
3
  import { Op, type WhereOptions } from 'sequelize';
4
4
  import { joinURL } from 'ufo';
5
5
 
6
+ import { BN } from '@ocap/util';
6
7
  import { getPaymentStat } from '../crons/payment-stat';
7
8
  import { getTokenSummaryByDid } from '../integrations/arcblock/stake';
8
9
  import { createListParamSchema, getOrder } from '../libs/api';
@@ -12,6 +13,7 @@ import { authenticate } from '../libs/security';
12
13
  import {
13
14
  EVMChainType,
14
15
  Invoice,
16
+ InvoiceItem,
15
17
  PaymentCurrency,
16
18
  PaymentIntent,
17
19
  PaymentMethod,
@@ -25,12 +27,19 @@ import logger from '../libs/logger';
25
27
 
26
28
  const router = Router();
27
29
  const auth = authenticate<PaymentStat>({ component: true, roles: ['owner', 'admin'] });
30
+ const BILLING_REASON_EXCLUSIONS = ['stake', 'stake_overdraft_protection', 'recharge'];
28
31
 
29
32
  const schema = createListParamSchema<{ currency_id?: string; start?: number; end?: number }>({
30
33
  currency_id: Joi.string().optional().empty(''),
31
34
  start: Joi.number().positive().optional().empty(''),
32
35
  end: Joi.number().positive().optional().empty(''),
33
36
  });
37
+
38
+ const summaryQuerySchema = Joi.object({
39
+ currency_id: Joi.string().optional().empty(''),
40
+ start: Joi.number().positive().optional().empty(''),
41
+ end: Joi.number().positive().optional().empty(''),
42
+ });
34
43
  router.get('/', auth, async (req, res) => {
35
44
  const { page, pageSize, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
36
45
  const where: WhereOptions = {};
@@ -41,17 +50,16 @@ router.get('/', auth, async (req, res) => {
41
50
  if (query.currency_id) {
42
51
  where.currency_id = query.currency_id;
43
52
  }
44
- where.timestamp = {};
45
- if (query.start) {
46
- where.timestamp[Op.gte] = query.start;
47
- } else {
48
- where.timestamp[Op.gte] = dayjs().subtract(30, 'days').startOf('day').unix();
49
- }
50
- if (query.end) {
51
- where.timestamp[Op.lt] = query.end;
52
- } else {
53
- where.timestamp[Op.lt] = dayjs().endOf('day').unix();
54
- }
53
+
54
+ const startTime = query.start || dayjs().subtract(30, 'days').startOf('day').unix();
55
+ const endTime = query.end || dayjs().endOf('day').unix();
56
+ const todayStart = dayjs().startOf('day').unix();
57
+ const todayEnd = dayjs().endOf('day').unix();
58
+
59
+ where.timestamp = {
60
+ [Op.gte]: startTime,
61
+ [Op.lte]: endTime,
62
+ };
55
63
 
56
64
  try {
57
65
  const { rows: list, count } = await PaymentStat.findAndCountAll({
@@ -60,9 +68,8 @@ router.get('/', auth, async (req, res) => {
60
68
  include: [],
61
69
  });
62
70
 
63
- // Append live data at the end
64
- const now = dayjs().unix();
65
- if (query.end && query.end >= now) {
71
+ const includesToday = startTime <= todayEnd && endTime >= todayStart;
72
+ if (includesToday) {
66
73
  const { stats, timestamp, currencies } = await getPaymentStat(dayjs().toDate().toString());
67
74
  list.push(
68
75
  // @ts-ignore
@@ -112,25 +119,240 @@ async function getCurrencyLinks(livemode: boolean) {
112
119
  }, {} as any);
113
120
  }
114
121
 
115
- // eslint-disable-next-line consistent-return
122
+ type RevenueStats = {
123
+ totalRevenue: string;
124
+ promotionCost: string;
125
+ vendorCost: string;
126
+ taxedRevenue: string;
127
+ netRevenue: string;
128
+ };
129
+
130
+ const ZERO_REVENUE_STATS: RevenueStats = {
131
+ totalRevenue: '0',
132
+ promotionCost: '0',
133
+ vendorCost: '0',
134
+ taxedRevenue: '0',
135
+ netRevenue: '0',
136
+ };
137
+
138
+ const buildCurrencyFilter = (currencyIds?: string[]) => {
139
+ if (!currencyIds || currencyIds.length === 0) {
140
+ return undefined;
141
+ }
142
+ if (currencyIds.length === 1) {
143
+ return currencyIds[0];
144
+ }
145
+ return { [Op.in]: currencyIds };
146
+ };
147
+
148
+ const addToMap = (map: Record<string, string>, key: string | null | undefined, value: string | number) => {
149
+ if (!key) {
150
+ return;
151
+ }
152
+ const previous = map[key] || '0';
153
+ map[key] = new BN(previous).add(new BN(value || '0')).toString();
154
+ };
155
+
156
+ const hasNonZeroValue = (values: string[]) => values.some((value) => !new BN(value || '0').isZero());
157
+
158
+ async function getTaxedInvoiceIds(
159
+ livemode: boolean,
160
+ currencyIds?: string[],
161
+ start?: number,
162
+ end?: number
163
+ ): Promise<Set<string>> {
164
+ const invoiceWhere: WhereOptions = {
165
+ livemode,
166
+ status: 'paid',
167
+ billing_reason: { [Op.notIn]: BILLING_REASON_EXCLUSIONS },
168
+ };
169
+
170
+ const currencyFilter = buildCurrencyFilter(currencyIds);
171
+ if (currencyFilter) {
172
+ invoiceWhere.currency_id = currencyFilter;
173
+ }
174
+
175
+ if (start || end) {
176
+ invoiceWhere.created_at = {};
177
+ if (start) {
178
+ invoiceWhere.created_at[Op.gte] = new Date(start * 1000);
179
+ }
180
+ if (end) {
181
+ invoiceWhere.created_at[Op.lt] = new Date(end * 1000);
182
+ }
183
+ }
184
+
185
+ const invoiceItems = await InvoiceItem.findAll({
186
+ where: {
187
+ tax_rate_id: { [Op.ne]: null },
188
+ } as any,
189
+ include: [
190
+ {
191
+ model: Invoice,
192
+ as: 'invoice',
193
+ where: invoiceWhere,
194
+ attributes: ['id'],
195
+ },
196
+ ],
197
+ attributes: ['invoice_id'],
198
+ });
199
+
200
+ return new Set(invoiceItems.map((item: any) => item.invoice_id).filter(Boolean));
201
+ }
202
+
203
+ async function getRevenueStats(livemode: boolean, currencyId?: string, start?: number, end?: number) {
204
+ const currencyFilter = currencyId ? [currencyId] : undefined;
205
+
206
+ const invoiceWhere: WhereOptions = {
207
+ livemode,
208
+ status: 'paid',
209
+ billing_reason: { [Op.notIn]: BILLING_REASON_EXCLUSIONS },
210
+ };
211
+
212
+ const invoiceCurrencyFilter = buildCurrencyFilter(currencyFilter);
213
+ if (invoiceCurrencyFilter) {
214
+ invoiceWhere.currency_id = invoiceCurrencyFilter;
215
+ }
216
+
217
+ if (start || end) {
218
+ invoiceWhere.created_at = {};
219
+ if (start) {
220
+ invoiceWhere.created_at[Op.gte] = new Date(start * 1000);
221
+ }
222
+ if (end) {
223
+ invoiceWhere.created_at[Op.lt] = new Date(end * 1000);
224
+ }
225
+ }
226
+
227
+ const [invoices, taxedInvoiceIds] = await Promise.all([
228
+ Invoice.findAll({
229
+ where: invoiceWhere,
230
+ attributes: ['id', 'total', 'total_discount_amounts', 'currency_id'],
231
+ }),
232
+ getTaxedInvoiceIds(livemode, currencyFilter, start, end),
233
+ ]);
234
+
235
+ const totalRevenueByCurrency: Record<string, string> = {};
236
+ const promotionCostByCurrency: Record<string, string> = {};
237
+ const taxedRevenueByCurrency: Record<string, string> = {};
238
+
239
+ invoices.forEach((inv) => {
240
+ addToMap(totalRevenueByCurrency, inv.currency_id, inv.total);
241
+
242
+ const discounts = inv.total_discount_amounts || [];
243
+ if (discounts.length > 0) {
244
+ const invoiceDiscount = discounts.reduce((sum: BN, discount: any) => {
245
+ const amount = discount.amount || 0;
246
+ return sum.add(new BN(amount));
247
+ }, new BN('0'));
248
+ if (!invoiceDiscount.isZero()) {
249
+ addToMap(promotionCostByCurrency, inv.currency_id, invoiceDiscount.toString());
250
+ }
251
+ }
252
+
253
+ if (taxedInvoiceIds.has(inv.id)) {
254
+ addToMap(taxedRevenueByCurrency, inv.currency_id, inv.total);
255
+ }
256
+ });
257
+
258
+ const payoutWhere: WhereOptions = {
259
+ livemode,
260
+ status: { [Op.in]: ['paid', 'deferred'] },
261
+ vendor_info: { [Op.ne]: null },
262
+ };
263
+
264
+ const payoutCurrencyFilter = buildCurrencyFilter(currencyFilter);
265
+ if (payoutCurrencyFilter) {
266
+ payoutWhere.currency_id = payoutCurrencyFilter;
267
+ }
268
+
269
+ if (start || end) {
270
+ payoutWhere.created_at = {};
271
+ if (start) {
272
+ payoutWhere.created_at[Op.gte] = new Date(start * 1000);
273
+ }
274
+ if (end) {
275
+ payoutWhere.created_at[Op.lt] = new Date(end * 1000);
276
+ }
277
+ }
278
+
279
+ const payouts = await Payout.findAll({
280
+ where: payoutWhere,
281
+ attributes: ['amount', 'currency_id'],
282
+ });
283
+
284
+ const vendorCostByCurrency: Record<string, string> = {};
285
+ payouts.forEach((payout) => {
286
+ addToMap(vendorCostByCurrency, payout.currency_id, payout.amount || '0');
287
+ });
288
+
289
+ const currencySet = new Set<string>(currencyFilter || []);
290
+ [totalRevenueByCurrency, promotionCostByCurrency, taxedRevenueByCurrency, vendorCostByCurrency].forEach((map) => {
291
+ Object.keys(map).forEach((id) => currencySet.add(id));
292
+ });
293
+
294
+ const currencyList = Array.from(currencySet);
295
+ const byCurrency: Record<string, RevenueStats> = {};
296
+
297
+ currencyList.forEach((id) => {
298
+ const totalRevenue = totalRevenueByCurrency[id] || '0';
299
+ const promotionCost = promotionCostByCurrency[id] || '0';
300
+ const taxedRevenue = taxedRevenueByCurrency[id] || '0';
301
+ const vendorCost = vendorCostByCurrency[id] || '0';
302
+
303
+ const includeEntry =
304
+ hasNonZeroValue([totalRevenue, promotionCost, taxedRevenue, vendorCost]) || Boolean(currencyFilter);
305
+
306
+ if (!includeEntry) {
307
+ return;
308
+ }
309
+
310
+ const netRevenue = new BN(totalRevenue).sub(new BN(vendorCost)).toString();
311
+
312
+ byCurrency[id] = {
313
+ totalRevenue,
314
+ promotionCost,
315
+ vendorCost,
316
+ taxedRevenue,
317
+ netRevenue,
318
+ };
319
+ });
320
+
321
+ if (currencyList.length === 0 && currencyId) {
322
+ byCurrency[currencyId] = { ...ZERO_REVENUE_STATS };
323
+ }
324
+
325
+ return byCurrency;
326
+ }
327
+
116
328
  router.get('/summary', auth, async (req, res) => {
117
329
  try {
118
- const [arcblock, ethereum, links] = await Promise.all([
330
+ const {
331
+ currency_id: currencyId,
332
+ start,
333
+ end,
334
+ } = await summaryQuerySchema.validateAsync(req.query, {
335
+ stripUnknown: true,
336
+ });
337
+ const livemode = !!req.livemode;
338
+ const [arcblock, ethereum, links, revenueByCurrency] = await Promise.all([
119
339
  getTokenSummaryByDid(wallet.address, !!req.livemode, 'arcblock'),
120
340
  getTokenSummaryByDid(ethWallet.address, !!req.livemode, EVM_CHAIN_TYPES),
121
- getCurrencyLinks(!!req.livemode),
341
+ getCurrencyLinks(livemode),
342
+ getRevenueStats(livemode, currencyId, start, end),
122
343
  ]);
123
344
  res.json({
124
345
  links,
125
346
  balances: { ...arcblock, ...ethereum },
126
347
  addresses: { arcblock: wallet.address, ethereum: ethWallet.address },
127
348
  summary: {
128
- subscription: await Subscription.getSummary(!!req.livemode),
129
- invoice: await Invoice.getSummary(!!req.livemode),
130
- payment: await PaymentIntent.getSummary(!!req.livemode),
131
- payout: await Payout.getSummary(!!req.livemode),
132
- refund: await Refund.getSummary(!!req.livemode),
349
+ subscription: await Subscription.getSummary(livemode),
350
+ invoice: await Invoice.getSummary(livemode),
351
+ payment: await PaymentIntent.getSummary(livemode),
352
+ payout: await Payout.getSummary(livemode),
353
+ refund: await Refund.getSummary(livemode),
133
354
  },
355
+ revenueByCurrency,
134
356
  });
135
357
  } catch (err) {
136
358
  logger.error(err);