payment-kit 1.25.8 → 1.25.10
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/index.ts +24 -0
- package/api/src/libs/archive/config.ts +254 -0
- package/api/src/libs/archive/executor.ts +729 -0
- package/api/src/libs/archive/index.ts +7 -0
- package/api/src/libs/archive/lock.ts +50 -0
- package/api/src/libs/archive/policy.ts +55 -0
- package/api/src/libs/archive/query.ts +136 -0
- package/api/src/libs/archive/snapshot.ts +291 -0
- package/api/src/libs/archive/store.ts +200 -0
- package/api/src/queues/archive.ts +32 -0
- package/api/src/routes/archive.ts +176 -0
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/payment-stats.ts +167 -20
- package/api/src/store/migrations/20260203-archive.ts +12 -0
- package/api/src/store/migrations/20260204-revenue-snapshot.ts +19 -0
- package/api/src/store/models/archive-lock.ts +55 -0
- package/api/src/store/models/archive-metadata.ts +132 -0
- package/api/src/store/models/index.ts +9 -0
- package/api/src/store/models/revenue-snapshot.ts +110 -0
- package/api/tests/libs/archive-config.spec.ts +185 -0
- package/api/tests/libs/archive-executor.spec.ts +678 -0
- package/api/tests/libs/archive-lock.spec.ts +130 -0
- package/api/tests/libs/archive-policy.spec.ts +255 -0
- package/api/tests/libs/archive-query.spec.ts +267 -0
- package/api/tests/libs/archive-store.spec.ts +159 -0
- package/blocklet.prefs.json +187 -0
- package/blocklet.yml +1 -1
- package/package.json +10 -10
- package/src/locales/en.tsx +4 -0
- package/src/locales/zh.tsx +4 -0
- package/src/pages/admin/overview.tsx +2 -0
- package/vite.config.ts +1 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ArchiveLock } from '../../store/models/archive-lock';
|
|
2
|
+
|
|
3
|
+
const LOCK_ID = 'archive_job';
|
|
4
|
+
const LOCK_TTL_MS = 2 * 60 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
export function acquireArchiveLock(instanceId: string): Promise<boolean> {
|
|
7
|
+
const { sequelize } = ArchiveLock;
|
|
8
|
+
if (!sequelize) {
|
|
9
|
+
return Promise.resolve(false);
|
|
10
|
+
}
|
|
11
|
+
const now = Date.now();
|
|
12
|
+
const expiresAt = now + LOCK_TTL_MS;
|
|
13
|
+
|
|
14
|
+
return sequelize.transaction(async (transaction) => {
|
|
15
|
+
const existing = await ArchiveLock.findByPk(LOCK_ID, { transaction });
|
|
16
|
+
if (!existing) {
|
|
17
|
+
await ArchiveLock.create(
|
|
18
|
+
{
|
|
19
|
+
id: LOCK_ID,
|
|
20
|
+
locked_by: instanceId,
|
|
21
|
+
locked_at: now,
|
|
22
|
+
expires_at: expiresAt,
|
|
23
|
+
},
|
|
24
|
+
{ transaction }
|
|
25
|
+
);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!existing.expires_at || existing.expires_at <= now) {
|
|
30
|
+
await existing.update(
|
|
31
|
+
{
|
|
32
|
+
locked_by: instanceId,
|
|
33
|
+
locked_at: now,
|
|
34
|
+
expires_at: expiresAt,
|
|
35
|
+
},
|
|
36
|
+
{ transaction }
|
|
37
|
+
);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return false;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function releaseArchiveLock(instanceId: string): Promise<void> {
|
|
46
|
+
await ArchiveLock.update(
|
|
47
|
+
{ locked_by: null, locked_at: null, expires_at: null },
|
|
48
|
+
{ where: { id: LOCK_ID, locked_by: instanceId } }
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Op, Sequelize, ModelStatic, Model } from 'sequelize';
|
|
2
|
+
|
|
3
|
+
import { TableRetentionPolicy } from './config';
|
|
4
|
+
|
|
5
|
+
export type ArchiveQueryPlan = {
|
|
6
|
+
where: Record<string, any>;
|
|
7
|
+
dateField: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function getArchiveDateField(model: ModelStatic<Model>): string {
|
|
11
|
+
const attributes = model.rawAttributes;
|
|
12
|
+
if (attributes.updated_at) {
|
|
13
|
+
return 'updated_at';
|
|
14
|
+
}
|
|
15
|
+
if (attributes.created_at) {
|
|
16
|
+
return 'created_at';
|
|
17
|
+
}
|
|
18
|
+
return 'created_at';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildArchiveQueryPlan(
|
|
22
|
+
model: ModelStatic<Model>,
|
|
23
|
+
policy: TableRetentionPolicy,
|
|
24
|
+
cutoffDate: Date
|
|
25
|
+
): ArchiveQueryPlan {
|
|
26
|
+
const dateField = getArchiveDateField(model);
|
|
27
|
+
const where: any = {
|
|
28
|
+
[dateField]: { [Op.lte]: cutoffDate },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const conditions: any[] = [];
|
|
32
|
+
|
|
33
|
+
if (model.rawAttributes.status) {
|
|
34
|
+
if (policy.archivableStatuses && policy.archivableStatuses.length > 0) {
|
|
35
|
+
conditions.push({ status: { [Op.in]: policy.archivableStatuses } });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (policy.excludeConditions?.statuses && policy.excludeConditions.statuses.length > 0) {
|
|
39
|
+
conditions.push({ status: { [Op.notIn]: policy.excludeConditions.statuses } });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (policy.excludeConditions?.customCondition) {
|
|
44
|
+
// customCondition is an EXCLUSION condition: records matching it are KEPT (not archived).
|
|
45
|
+
// E.g. customCondition='pending_webhooks > 0' means "exclude records with pending webhooks".
|
|
46
|
+
// We wrap with NOT() so the query selects records that do NOT match the exclusion.
|
|
47
|
+
conditions.push(Sequelize.literal(`NOT (${policy.excludeConditions.customCondition})`));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (conditions.length > 0) {
|
|
51
|
+
where[Op.and] = conditions;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { where, dateField };
|
|
55
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/* eslint-disable no-continue */
|
|
2
|
+
/* eslint-disable no-await-in-loop */
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
import { QueryTypes, Op } from 'sequelize';
|
|
6
|
+
|
|
7
|
+
import logger from '../logger';
|
|
8
|
+
import { ArchiveMetadata } from '../../store/models/archive-metadata';
|
|
9
|
+
import { listArchiveFiles, openArchiveSequelize } from './store';
|
|
10
|
+
|
|
11
|
+
type ArchiveQueryParams = {
|
|
12
|
+
table: string;
|
|
13
|
+
id?: string;
|
|
14
|
+
customer_id?: string;
|
|
15
|
+
from: number;
|
|
16
|
+
to?: number;
|
|
17
|
+
page: number;
|
|
18
|
+
limit: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type ArchiveQueryResult = {
|
|
22
|
+
data: any[];
|
|
23
|
+
total: number;
|
|
24
|
+
archiveFiles: string[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function buildWhereClause(params: ArchiveQueryParams) {
|
|
28
|
+
const conditions: string[] = [];
|
|
29
|
+
const replacements: Record<string, any> = {};
|
|
30
|
+
|
|
31
|
+
// Filter by data's original created_at, not archived_at
|
|
32
|
+
// Users query for "2024 invoices", not "invoices archived at some time"
|
|
33
|
+
const fromDate = new Date(params.from * 1000);
|
|
34
|
+
const toDate = new Date((params.to ?? Math.floor(Date.now() / 1000)) * 1000);
|
|
35
|
+
|
|
36
|
+
conditions.push('created_at >= :from');
|
|
37
|
+
conditions.push('created_at <= :to');
|
|
38
|
+
replacements.from = fromDate;
|
|
39
|
+
replacements.to = toDate;
|
|
40
|
+
|
|
41
|
+
if (params.id) {
|
|
42
|
+
conditions.push('id = :id');
|
|
43
|
+
replacements.id = params.id;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (params.customer_id) {
|
|
47
|
+
if (params.table === 'meter_events') {
|
|
48
|
+
conditions.push("json_extract(payload, '$.customer_id') = :customerId");
|
|
49
|
+
} else {
|
|
50
|
+
conditions.push('customer_id = :customerId');
|
|
51
|
+
}
|
|
52
|
+
replacements.customerId = params.customer_id;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { clause: conditions.join(' AND '), replacements };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function recordArchiveQuery(fileNames: string[], actorId?: string) {
|
|
59
|
+
const uniqueFiles = Array.from(new Set(fileNames));
|
|
60
|
+
// Find all metadata records that contain any of the queried files
|
|
61
|
+
// archive_file may contain comma-separated file names like "archive-2024.db,archive-2025.db"
|
|
62
|
+
const updatedIds = new Set<string>();
|
|
63
|
+
|
|
64
|
+
for (const fileName of uniqueFiles) {
|
|
65
|
+
const metadataList = await ArchiveMetadata.findAll({
|
|
66
|
+
where: { archive_file: { [Op.like]: `%${fileName}%` } },
|
|
67
|
+
});
|
|
68
|
+
for (const metadata of metadataList) {
|
|
69
|
+
// Avoid updating the same metadata multiple times in one query
|
|
70
|
+
if (updatedIds.has(metadata.id)) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
updatedIds.add(metadata.id);
|
|
74
|
+
const currentActors = metadata.query_actor_ids || [];
|
|
75
|
+
const actorIds = actorId ? Array.from(new Set([...currentActors, actorId])) : currentActors;
|
|
76
|
+
await metadata.update({
|
|
77
|
+
query_count: (metadata.query_count || 0) + 1,
|
|
78
|
+
query_actor_ids: actorIds,
|
|
79
|
+
last_queried_at: Math.floor(Date.now() / 1000),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const VALID_TABLE_NAME = /^[a-z][a-z0-9_]*$/;
|
|
86
|
+
|
|
87
|
+
export async function queryArchive(params: ArchiveQueryParams, actorId?: string): Promise<ArchiveQueryResult> {
|
|
88
|
+
if (!VALID_TABLE_NAME.test(params.table)) {
|
|
89
|
+
throw new Error(`Invalid table name: ${params.table}`);
|
|
90
|
+
}
|
|
91
|
+
const archiveFiles = listArchiveFiles();
|
|
92
|
+
const { clause, replacements } = buildWhereClause(params);
|
|
93
|
+
const results: any[] = [];
|
|
94
|
+
const touchedFiles: string[] = [];
|
|
95
|
+
|
|
96
|
+
for (const filePath of archiveFiles) {
|
|
97
|
+
const archiveSequelize = openArchiveSequelize(filePath);
|
|
98
|
+
try {
|
|
99
|
+
// Check if table exists using PRAGMA instead of describeTable
|
|
100
|
+
// (describeTable has a Sequelize SQLite bug: "Cannot set properties of undefined (setting 'unique')")
|
|
101
|
+
const [tables] = await archiveSequelize.query(
|
|
102
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=:tableName",
|
|
103
|
+
{ replacements: { tableName: params.table } }
|
|
104
|
+
);
|
|
105
|
+
if (!Array.isArray(tables) || tables.length === 0) {
|
|
106
|
+
// Table doesn't exist in this archive file, skip to next file
|
|
107
|
+
// Note: don't close here, finally block will handle it
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const sql = `SELECT * FROM "${params.table}" WHERE ${clause} ORDER BY created_at DESC`;
|
|
112
|
+
const rows = (await archiveSequelize.query(sql, {
|
|
113
|
+
replacements,
|
|
114
|
+
type: QueryTypes.SELECT,
|
|
115
|
+
})) as any[];
|
|
116
|
+
if (Array.isArray(rows) && rows.length > 0) {
|
|
117
|
+
results.push(...rows);
|
|
118
|
+
touchedFiles.push(path.basename(filePath));
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
logger.warn('archive query failed', { filePath, error });
|
|
122
|
+
} finally {
|
|
123
|
+
await archiveSequelize.close();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
results.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
|
128
|
+
const total = results.length;
|
|
129
|
+
const start = (params.page - 1) * params.limit;
|
|
130
|
+
const end = start + params.limit;
|
|
131
|
+
const data = results.slice(start, end);
|
|
132
|
+
|
|
133
|
+
await recordArchiveQuery(touchedFiles, actorId);
|
|
134
|
+
|
|
135
|
+
return { data, total, archiveFiles: touchedFiles };
|
|
136
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/* eslint-disable no-await-in-loop */
|
|
2
|
+
import { BN } from '@ocap/util';
|
|
3
|
+
import { Op } from 'sequelize';
|
|
4
|
+
|
|
5
|
+
import dayjs from '../dayjs';
|
|
6
|
+
import logger from '../logger';
|
|
7
|
+
import {
|
|
8
|
+
CreditGrant,
|
|
9
|
+
Invoice,
|
|
10
|
+
InvoiceItem,
|
|
11
|
+
Payout,
|
|
12
|
+
PaymentCurrency,
|
|
13
|
+
Refund,
|
|
14
|
+
RevenueSnapshot,
|
|
15
|
+
} from '../../store/models';
|
|
16
|
+
|
|
17
|
+
const BILLING_REASON_EXCLUSIONS = ['stake', 'stake_overdraft_protection', 'recharge'];
|
|
18
|
+
|
|
19
|
+
type MonthlyRevenueData = {
|
|
20
|
+
currencyId: string;
|
|
21
|
+
livemode: boolean;
|
|
22
|
+
totalRevenue: string;
|
|
23
|
+
refundAmount: string;
|
|
24
|
+
promotionCost: string;
|
|
25
|
+
creditGrantCost: string;
|
|
26
|
+
vendorCost: string;
|
|
27
|
+
taxedRevenue: string;
|
|
28
|
+
netRevenue: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
async function getTaxedInvoiceIds(
|
|
32
|
+
livemode: boolean,
|
|
33
|
+
startDate: Date,
|
|
34
|
+
endDate: Date,
|
|
35
|
+
currencyIds: string[]
|
|
36
|
+
): Promise<Set<string>> {
|
|
37
|
+
const invoiceItems = await InvoiceItem.findAll({
|
|
38
|
+
where: {
|
|
39
|
+
tax_rate_id: { [Op.ne]: null },
|
|
40
|
+
} as any,
|
|
41
|
+
include: [
|
|
42
|
+
{
|
|
43
|
+
model: Invoice,
|
|
44
|
+
as: 'invoice',
|
|
45
|
+
where: {
|
|
46
|
+
livemode,
|
|
47
|
+
status: 'paid',
|
|
48
|
+
billing_reason: { [Op.notIn]: BILLING_REASON_EXCLUSIONS },
|
|
49
|
+
currency_id: { [Op.in]: currencyIds },
|
|
50
|
+
created_at: { [Op.gte]: startDate, [Op.lt]: endDate },
|
|
51
|
+
},
|
|
52
|
+
attributes: ['id'],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
attributes: ['invoice_id'],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return new Set(invoiceItems.map((item: any) => item.invoice_id).filter(Boolean));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function calculateMonthlyRevenue(
|
|
62
|
+
livemode: boolean,
|
|
63
|
+
startDate: Date,
|
|
64
|
+
endDate: Date
|
|
65
|
+
): Promise<MonthlyRevenueData[]> {
|
|
66
|
+
const currencies = await PaymentCurrency.findAll({ where: { livemode } });
|
|
67
|
+
if (currencies.length === 0) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const currencyIds = currencies.map((c) => c.id);
|
|
72
|
+
|
|
73
|
+
const [invoices, taxedInvoiceIds, payouts, refunds, creditGrants] = await Promise.all([
|
|
74
|
+
Invoice.findAll({
|
|
75
|
+
where: {
|
|
76
|
+
livemode,
|
|
77
|
+
status: 'paid',
|
|
78
|
+
billing_reason: { [Op.notIn]: BILLING_REASON_EXCLUSIONS },
|
|
79
|
+
currency_id: { [Op.in]: currencyIds },
|
|
80
|
+
created_at: { [Op.gte]: startDate, [Op.lt]: endDate },
|
|
81
|
+
},
|
|
82
|
+
attributes: ['id', 'total', 'total_discount_amounts', 'currency_id'],
|
|
83
|
+
}),
|
|
84
|
+
getTaxedInvoiceIds(livemode, startDate, endDate, currencyIds),
|
|
85
|
+
Payout.findAll({
|
|
86
|
+
where: {
|
|
87
|
+
livemode,
|
|
88
|
+
status: { [Op.in]: ['paid', 'deferred'] },
|
|
89
|
+
currency_id: { [Op.in]: currencyIds },
|
|
90
|
+
created_at: { [Op.gte]: startDate, [Op.lt]: endDate },
|
|
91
|
+
} as any,
|
|
92
|
+
attributes: ['amount', 'currency_id', 'vendor_info'],
|
|
93
|
+
}).then((result) => result.filter((p: any) => p.vendor_info != null)),
|
|
94
|
+
Refund.findAll({
|
|
95
|
+
where: {
|
|
96
|
+
livemode,
|
|
97
|
+
status: 'succeeded',
|
|
98
|
+
currency_id: { [Op.in]: currencyIds },
|
|
99
|
+
created_at: { [Op.gte]: startDate, [Op.lt]: endDate },
|
|
100
|
+
},
|
|
101
|
+
attributes: ['amount', 'currency_id'],
|
|
102
|
+
}),
|
|
103
|
+
// Only count CONSUMED credits as cost (depleted or expired)
|
|
104
|
+
// granted credits are still active, voided credits were revoked
|
|
105
|
+
// Cost = amount - remaining_amount (the actually consumed portion)
|
|
106
|
+
CreditGrant.findAll({
|
|
107
|
+
where: {
|
|
108
|
+
livemode,
|
|
109
|
+
status: { [Op.in]: ['depleted', 'expired'] },
|
|
110
|
+
currency_id: { [Op.in]: currencyIds },
|
|
111
|
+
created_at: { [Op.gte]: startDate, [Op.lt]: endDate },
|
|
112
|
+
},
|
|
113
|
+
attributes: ['amount', 'remaining_amount', 'currency_id'],
|
|
114
|
+
}),
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
const byCurrency: Record<
|
|
118
|
+
string,
|
|
119
|
+
{
|
|
120
|
+
totalRevenue: BN;
|
|
121
|
+
refundAmount: BN;
|
|
122
|
+
promotionCost: BN;
|
|
123
|
+
creditGrantCost: BN;
|
|
124
|
+
vendorCost: BN;
|
|
125
|
+
taxedRevenue: BN;
|
|
126
|
+
livemode: boolean;
|
|
127
|
+
}
|
|
128
|
+
> = {};
|
|
129
|
+
|
|
130
|
+
for (const currency of currencies) {
|
|
131
|
+
byCurrency[currency.id] = {
|
|
132
|
+
totalRevenue: new BN('0'),
|
|
133
|
+
refundAmount: new BN('0'),
|
|
134
|
+
promotionCost: new BN('0'),
|
|
135
|
+
creditGrantCost: new BN('0'),
|
|
136
|
+
vendorCost: new BN('0'),
|
|
137
|
+
taxedRevenue: new BN('0'),
|
|
138
|
+
livemode: currency.livemode,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const inv of invoices) {
|
|
143
|
+
const entry = byCurrency[inv.currency_id];
|
|
144
|
+
if (entry) {
|
|
145
|
+
entry.totalRevenue = entry.totalRevenue.add(new BN(inv.total || '0'));
|
|
146
|
+
|
|
147
|
+
const discounts = inv.total_discount_amounts || [];
|
|
148
|
+
for (const discount of discounts) {
|
|
149
|
+
entry.promotionCost = entry.promotionCost.add(new BN(discount.amount || '0'));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (taxedInvoiceIds.has(inv.id)) {
|
|
153
|
+
entry.taxedRevenue = entry.taxedRevenue.add(new BN(inv.total || '0'));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const payout of payouts) {
|
|
159
|
+
const entry = byCurrency[payout.currency_id];
|
|
160
|
+
if (entry) {
|
|
161
|
+
entry.vendorCost = entry.vendorCost.add(new BN(payout.amount || '0'));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const refund of refunds) {
|
|
166
|
+
const entry = byCurrency[refund.currency_id];
|
|
167
|
+
if (entry) {
|
|
168
|
+
entry.refundAmount = entry.refundAmount.add(new BN(refund.amount || '0'));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const grant of creditGrants) {
|
|
173
|
+
const entry = byCurrency[grant.currency_id];
|
|
174
|
+
if (entry) {
|
|
175
|
+
// Cost = consumed amount = total amount - remaining amount
|
|
176
|
+
const consumed = new BN(grant.amount || '0').sub(new BN(grant.remaining_amount || '0'));
|
|
177
|
+
entry.creditGrantCost = entry.creditGrantCost.add(consumed);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const results: MonthlyRevenueData[] = [];
|
|
182
|
+
for (const [currencyId, data] of Object.entries(byCurrency)) {
|
|
183
|
+
// Net Revenue = Total Revenue - Refunds
|
|
184
|
+
const netRevenue = data.totalRevenue.sub(data.refundAmount);
|
|
185
|
+
results.push({
|
|
186
|
+
currencyId,
|
|
187
|
+
livemode: data.livemode,
|
|
188
|
+
totalRevenue: data.totalRevenue.toString(),
|
|
189
|
+
refundAmount: data.refundAmount.toString(),
|
|
190
|
+
promotionCost: data.promotionCost.toString(),
|
|
191
|
+
creditGrantCost: data.creditGrantCost.toString(),
|
|
192
|
+
vendorCost: data.vendorCost.toString(),
|
|
193
|
+
taxedRevenue: data.taxedRevenue.toString(),
|
|
194
|
+
netRevenue: netRevenue.toString(),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return results;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function createRevenueSnapshotsForArchive(
|
|
202
|
+
cutoffTimestamp: number,
|
|
203
|
+
archiveMetadataId: string
|
|
204
|
+
): Promise<number> {
|
|
205
|
+
const cutoffDate = dayjs.unix(cutoffTimestamp);
|
|
206
|
+
const monthsToSnapshot: { start: dayjs.Dayjs; end: dayjs.Dayjs }[] = [];
|
|
207
|
+
|
|
208
|
+
const existingSnapshots = await RevenueSnapshot.findAll({
|
|
209
|
+
attributes: ['timestamp', 'livemode', 'currency_id'],
|
|
210
|
+
order: [['timestamp', 'DESC']],
|
|
211
|
+
limit: 1,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
let startMonth: dayjs.Dayjs;
|
|
215
|
+
if (existingSnapshots.length > 0) {
|
|
216
|
+
startMonth = dayjs.unix(existingSnapshots[0]!.timestamp).add(1, 'month').startOf('month');
|
|
217
|
+
} else {
|
|
218
|
+
const oldestInvoice = await Invoice.findOne({
|
|
219
|
+
where: { status: 'paid' },
|
|
220
|
+
order: [['created_at', 'ASC']],
|
|
221
|
+
attributes: ['created_at'],
|
|
222
|
+
});
|
|
223
|
+
if (!oldestInvoice) {
|
|
224
|
+
logger.info('No paid invoices found, skipping revenue snapshot');
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
startMonth = dayjs(oldestInvoice.created_at).startOf('month');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let current = startMonth;
|
|
231
|
+
while (current.isBefore(cutoffDate)) {
|
|
232
|
+
const monthEnd = current.endOf('month');
|
|
233
|
+
if (monthEnd.isBefore(cutoffDate)) {
|
|
234
|
+
monthsToSnapshot.push({
|
|
235
|
+
start: current,
|
|
236
|
+
end: current.add(1, 'month').startOf('month'),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
current = current.add(1, 'month').startOf('month');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (monthsToSnapshot.length === 0) {
|
|
243
|
+
logger.info('No complete months to snapshot before cutoff', { cutoffDate: cutoffDate.toISOString() });
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let totalCreated = 0;
|
|
248
|
+
|
|
249
|
+
for (const { start, end } of monthsToSnapshot) {
|
|
250
|
+
const timestamp = start.unix();
|
|
251
|
+
|
|
252
|
+
for (const livemode of [true, false]) {
|
|
253
|
+
const revenueData = await calculateMonthlyRevenue(livemode, start.toDate(), end.toDate());
|
|
254
|
+
|
|
255
|
+
const snapshots = revenueData.map((data) => ({
|
|
256
|
+
livemode: data.livemode,
|
|
257
|
+
currency_id: data.currencyId,
|
|
258
|
+
timestamp,
|
|
259
|
+
period_type: 'monthly' as const,
|
|
260
|
+
total_revenue: data.totalRevenue,
|
|
261
|
+
refund_amount: data.refundAmount,
|
|
262
|
+
promotion_cost: data.promotionCost,
|
|
263
|
+
credit_grant_cost: data.creditGrantCost,
|
|
264
|
+
vendor_cost: data.vendorCost,
|
|
265
|
+
taxed_revenue: data.taxedRevenue,
|
|
266
|
+
net_revenue: data.netRevenue,
|
|
267
|
+
archive_metadata_id: archiveMetadataId,
|
|
268
|
+
}));
|
|
269
|
+
|
|
270
|
+
if (snapshots.length > 0) {
|
|
271
|
+
await RevenueSnapshot.bulkCreate(snapshots, {
|
|
272
|
+
updateOnDuplicate: [
|
|
273
|
+
'total_revenue',
|
|
274
|
+
'refund_amount',
|
|
275
|
+
'promotion_cost',
|
|
276
|
+
'credit_grant_cost',
|
|
277
|
+
'vendor_cost',
|
|
278
|
+
'taxed_revenue',
|
|
279
|
+
'net_revenue',
|
|
280
|
+
'archive_metadata_id',
|
|
281
|
+
],
|
|
282
|
+
});
|
|
283
|
+
totalCreated += snapshots.length;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
logger.info('Revenue snapshot created', { month: start.format('YYYY-MM'), records: totalCreated });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return totalCreated;
|
|
291
|
+
}
|