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.
@@ -0,0 +1,7 @@
1
+ export * from './config';
2
+ export * from './executor';
3
+ export * from './lock';
4
+ export * from './policy';
5
+ export * from './query';
6
+ export * from './snapshot';
7
+ export * from './store';
@@ -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
+ }