payment-kit 1.21.12 → 1.21.14
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/crons/payment-stat.ts +31 -23
- package/api/src/libs/invoice.ts +29 -4
- package/api/src/libs/product.ts +28 -4
- package/api/src/routes/checkout-sessions.ts +46 -1
- package/api/src/routes/connect/re-stake.ts +2 -0
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/invoices.ts +63 -2
- package/api/src/routes/payment-stats.ts +244 -22
- package/api/src/routes/products.ts +3 -0
- package/api/src/routes/subscriptions.ts +2 -1
- package/api/src/routes/tax-rates.ts +220 -0
- package/api/src/store/migrations/20251001-add-tax-code-to-products.ts +20 -0
- package/api/src/store/migrations/20251001-create-tax-rates.ts +17 -0
- package/api/src/store/migrations/20251007-relate-tax-rate-to-invoice.ts +24 -0
- package/api/src/store/migrations/20251009-add-tax-behavior.ts +21 -0
- package/api/src/store/models/index.ts +3 -0
- package/api/src/store/models/invoice-item.ts +10 -0
- package/api/src/store/models/price.ts +7 -0
- package/api/src/store/models/product.ts +7 -0
- package/api/src/store/models/tax-rate.ts +352 -0
- package/api/tests/models/tax-rate.spec.ts +777 -0
- package/blocklet.yml +2 -2
- package/package.json +6 -6
- package/public/currencies/dollar.png +0 -0
- package/src/components/collapse.tsx +3 -2
- package/src/components/drawer-form.tsx +2 -1
- package/src/components/invoice/list.tsx +38 -1
- package/src/components/invoice/table.tsx +48 -2
- package/src/components/metadata/form.tsx +2 -2
- package/src/components/payment-intent/list.tsx +19 -1
- package/src/components/payouts/list.tsx +19 -1
- package/src/components/price/currency-select.tsx +105 -48
- package/src/components/price/form.tsx +3 -1
- package/src/components/product/form.tsx +79 -5
- package/src/components/refund/list.tsx +20 -1
- package/src/components/subscription/items/actions.tsx +25 -15
- package/src/components/subscription/list.tsx +16 -1
- package/src/components/tax/actions.tsx +140 -0
- package/src/components/tax/filter-toolbar.tsx +230 -0
- package/src/components/tax/tax-code-select.tsx +633 -0
- package/src/components/tax/tax-rate-form.tsx +177 -0
- package/src/components/tax/tax-utils.ts +38 -0
- package/src/components/tax/taxCodes.json +10882 -0
- package/src/components/uploader.tsx +3 -0
- package/src/locales/en.tsx +152 -0
- package/src/locales/zh.tsx +149 -0
- package/src/pages/admin/billing/invoices/detail.tsx +1 -1
- package/src/pages/admin/index.tsx +2 -0
- package/src/pages/admin/overview.tsx +1114 -322
- package/src/pages/admin/products/vendors/index.tsx +4 -2
- package/src/pages/admin/tax/create.tsx +104 -0
- package/src/pages/admin/tax/detail.tsx +476 -0
- package/src/pages/admin/tax/edit.tsx +126 -0
- package/src/pages/admin/tax/index.tsx +86 -0
- package/src/pages/admin/tax/list.tsx +334 -0
- package/src/pages/customer/subscription/change-payment.tsx +1 -1
- 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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
}
|
package/api/src/libs/invoice.ts
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/api/src/libs/product.ts
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
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[]) => {
|
|
@@ -87,6 +87,8 @@ export default {
|
|
|
87
87
|
const client = method.getStripeClient();
|
|
88
88
|
await client.subscriptions.update(subscription.payment_details.stripe.subscription_id, {
|
|
89
89
|
cancel_at_period_end: false,
|
|
90
|
+
});
|
|
91
|
+
await client.subscriptions.update(subscription.payment_details.stripe.subscription_id, {
|
|
90
92
|
cancel_at: null,
|
|
91
93
|
});
|
|
92
94
|
}
|
package/api/src/routes/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
129
|
-
invoice: await Invoice.getSummary(
|
|
130
|
-
payment: await PaymentIntent.getSummary(
|
|
131
|
-
payout: await Payout.getSummary(
|
|
132
|
-
refund: await Refund.getSummary(
|
|
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);
|