@dodopayments/opencode-plugin 0.6.1 → 0.6.3
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/package.json +1 -1
- package/skills/billing-sdk/SKILL.md +8 -193
- package/skills/billing-sdk/references/pricing-page.md +197 -0
- package/skills/checkout-integration/SKILL.md +14 -449
- package/skills/checkout-integration/references/overlay-checkout.md +148 -0
- package/skills/checkout-integration/references/server-examples.md +317 -0
- package/skills/credit-based-billing/SKILL.md +5 -151
- package/skills/credit-based-billing/references/ai-token-example.md +108 -0
- package/skills/credit-based-billing/references/credit-webhooks.md +51 -0
- package/skills/framework-adapters/SKILL.md +38 -527
- package/skills/framework-adapters/references/astro.md +52 -0
- package/skills/framework-adapters/references/bun.md +45 -0
- package/skills/framework-adapters/references/convex.md +91 -0
- package/skills/framework-adapters/references/express.md +72 -0
- package/skills/framework-adapters/references/fastify.md +54 -0
- package/skills/framework-adapters/references/hono.md +63 -0
- package/skills/framework-adapters/references/nuxt.md +53 -0
- package/skills/framework-adapters/references/remix.md +65 -0
- package/skills/framework-adapters/references/sveltekit.md +49 -0
- package/skills/framework-adapters/references/tanstack-start.md +31 -0
- package/skills/license-keys/SKILL.md +8 -303
- package/skills/license-keys/references/cli-tool.md +143 -0
- package/skills/license-keys/references/desktop-app.md +170 -0
- package/skills/mobile-checkout/SKILL.md +25 -313
- package/skills/mobile-checkout/references/android.md +69 -0
- package/skills/mobile-checkout/references/flutter.md +97 -0
- package/skills/mobile-checkout/references/ios.md +66 -0
- package/skills/mobile-checkout/references/react-native.md +100 -0
- package/skills/refunds-and-disputes/SKILL.md +1 -1
- package/skills/subscription-integration/SKILL.md +3 -77
- package/skills/subscription-integration/references/webhook-events.md +82 -0
- package/skills/usage-based-billing/SKILL.md +5 -114
- package/skills/usage-based-billing/references/instrumentation.md +119 -0
- package/skills/webhook-integration/SKILL.md +28 -277
- package/skills/webhook-integration/references/common-mistakes.md +146 -0
- package/skills/webhook-integration/references/event-catalog.md +103 -0
- package/skills/webhook-integration/references/local-testing.md +42 -0
|
@@ -350,4 +350,4 @@ async function reconcileRefund(refund) {
|
|
|
350
350
|
- [Refunds API](https://docs.dodopayments.com/api-reference/refunds/post-refunds)
|
|
351
351
|
- [Refunds feature guide](https://docs.dodopayments.com/features/transactions/refunds)
|
|
352
352
|
- [Dispute webhooks](https://docs.dodopayments.com/developer-resources/webhooks/intents/dispute)
|
|
353
|
-
-
|
|
353
|
+
- The `webhook-integration` skill for signature verification
|
|
@@ -347,85 +347,11 @@ For full customer management (creating, updating, listing), see the `customer-ma
|
|
|
347
347
|
|
|
348
348
|
## Webhook Events
|
|
349
349
|
|
|
350
|
-
|
|
351
|
-
|-------|------|--------|
|
|
352
|
-
| `subscription.active` | Subscription becomes active, including a trial start or recovery | Grant access |
|
|
353
|
-
| `subscription.updated` | Any subscription field changes | Sync your stored copy (status, dates, metadata) |
|
|
354
|
-
| `subscription.renewed` | Successful renewal | Log renewal, send receipt |
|
|
355
|
-
| `subscription.past_due` | Renewal failed and the grace period opened (payload has `past_due_ends_at`) | Keep access, prompt for payment before the deadline |
|
|
356
|
-
| `subscription.on_hold` | Renewal/plan-change payment failed | Notify customer, offer recovery |
|
|
357
|
-
| `subscription.paused` | Subscription paused | Revoke access while paused |
|
|
358
|
-
| `subscription.unpaused` | Paused subscription resumed | Restore access |
|
|
359
|
-
| `subscription.plan_changed` | Plan upgraded/downgraded or add-ons changed | Update entitlements |
|
|
360
|
-
| `subscription.update_payment_method` | Payment method updated | Update stored payment details |
|
|
361
|
-
| `subscription.cancelled` | Customer cancels | Schedule access revocation per `cancel_at_next_billing_date` |
|
|
362
|
-
| `subscription.failed` | Initial creation failed (mandate creation) | Notify customer, offer a new subscription |
|
|
363
|
-
| `subscription.expired` | Subscription term ended | Revoke access |
|
|
364
|
-
|
|
365
|
-
Webhook signature verification, raw-body handling, durable processing, and idempotency are covered in the `webhook-integration` skill.
|
|
366
|
-
|
|
367
|
-
### Example Handler
|
|
350
|
+
Full guide: [references/webhook-events.md](references/webhook-events.md).
|
|
368
351
|
|
|
369
|
-
|
|
370
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
371
|
-
|
|
372
|
-
export async function POST(req: NextRequest) {
|
|
373
|
-
const raw = await req.text();
|
|
374
|
-
const headers = Object.fromEntries(req.headers.entries());
|
|
375
|
-
const event = await client.webhooks.unwrap(raw, { headers });
|
|
376
|
-
const webhookId = req.headers.get('webhook-id');
|
|
377
|
-
|
|
378
|
-
if (!webhookId) {
|
|
379
|
-
return NextResponse.json({ error: 'Missing webhook-id' }, { status: 400 });
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// Implement this as an atomic insert backed by a UNIQUE constraint.
|
|
383
|
-
// Keep the claim and entitlement changes in the same database transaction.
|
|
384
|
-
const claimed = await claimWebhookId(webhookId);
|
|
385
|
-
if (!claimed) {
|
|
386
|
-
return NextResponse.json({ received: true });
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
switch (event.type) {
|
|
390
|
-
case 'subscription.active':
|
|
391
|
-
await grantAccess(event.data.customer.customer_id, event.data.product_id);
|
|
392
|
-
break;
|
|
393
|
-
case 'subscription.on_hold':
|
|
394
|
-
await notifyPaymentFailed(event.data.customer.customer_id);
|
|
395
|
-
break;
|
|
396
|
-
case 'subscription.paused':
|
|
397
|
-
case 'subscription.unpaused': {
|
|
398
|
-
// Pause and resume can happen in quick succession, and handlers for
|
|
399
|
-
// different events can finish in any order. Don't trust the event type:
|
|
400
|
-
// re-read the subscription and apply its CURRENT status, scoped to this
|
|
401
|
-
// subscription (the customer may hold other active ones). Run this under
|
|
402
|
-
// a per-subscription lock if your handlers execute concurrently.
|
|
403
|
-
const current = await client.subscriptions.retrieve(event.data.subscription_id);
|
|
404
|
-
if (current.status === 'paused') {
|
|
405
|
-
await suspendSubscriptionAccess(current.subscription_id);
|
|
406
|
-
} else if (current.status === 'active' || current.status === 'past_due') {
|
|
407
|
-
// past_due keeps access during the payment grace period
|
|
408
|
-
await restoreSubscriptionAccess(current.subscription_id);
|
|
409
|
-
}
|
|
410
|
-
break;
|
|
411
|
-
}
|
|
412
|
-
case 'subscription.cancelled':
|
|
413
|
-
if (event.data.cancel_at_next_billing_date) {
|
|
414
|
-
await scheduleAccessRevocation(event.data.subscription_id, new Date(event.data.next_billing_date));
|
|
415
|
-
} else {
|
|
416
|
-
await revokeAccessImmediately(event.data.subscription_id);
|
|
417
|
-
}
|
|
418
|
-
break;
|
|
419
|
-
case 'subscription.expired':
|
|
420
|
-
await revokeAccess(event.data.customer.customer_id);
|
|
421
|
-
break;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
return NextResponse.json({ received: true });
|
|
425
|
-
}
|
|
426
|
-
```
|
|
352
|
+
Covers:
|
|
427
353
|
|
|
428
|
-
|
|
354
|
+
- Example Handler
|
|
429
355
|
|
|
430
356
|
## Credit Entitlements
|
|
431
357
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Dodo Payments Subscription Integration: Webhook Events
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
| Event | When | Action |
|
|
5
|
+
|-------|------|--------|
|
|
6
|
+
| `subscription.active` | Subscription becomes active, including a trial start or recovery | Grant access |
|
|
7
|
+
| `subscription.updated` | Any subscription field changes | Sync your stored copy (status, dates, metadata) |
|
|
8
|
+
| `subscription.renewed` | Successful renewal | Log renewal, send receipt |
|
|
9
|
+
| `subscription.past_due` | Renewal failed and the grace period opened (payload has `past_due_ends_at`) | Keep access, prompt for payment before the deadline |
|
|
10
|
+
| `subscription.on_hold` | Renewal/plan-change payment failed | Notify customer, offer recovery |
|
|
11
|
+
| `subscription.paused` | Subscription paused | Revoke access while paused |
|
|
12
|
+
| `subscription.unpaused` | Paused subscription resumed | Restore access |
|
|
13
|
+
| `subscription.plan_changed` | Plan upgraded/downgraded or add-ons changed | Update entitlements |
|
|
14
|
+
| `subscription.update_payment_method` | Payment method updated | Update stored payment details |
|
|
15
|
+
| `subscription.cancelled` | Customer cancels | Schedule access revocation per `cancel_at_next_billing_date` |
|
|
16
|
+
| `subscription.failed` | Initial creation failed (mandate creation) | Notify customer, offer a new subscription |
|
|
17
|
+
| `subscription.expired` | Subscription term ended | Revoke access |
|
|
18
|
+
|
|
19
|
+
Webhook signature verification, raw-body handling, durable processing, and idempotency are covered in the `webhook-integration` skill.
|
|
20
|
+
|
|
21
|
+
### Example Handler
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
25
|
+
|
|
26
|
+
export async function POST(req: NextRequest) {
|
|
27
|
+
const raw = await req.text();
|
|
28
|
+
const headers = Object.fromEntries(req.headers.entries());
|
|
29
|
+
const event = await client.webhooks.unwrap(raw, { headers });
|
|
30
|
+
const webhookId = req.headers.get('webhook-id');
|
|
31
|
+
|
|
32
|
+
if (!webhookId) {
|
|
33
|
+
return NextResponse.json({ error: 'Missing webhook-id' }, { status: 400 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Implement this as an atomic insert backed by a UNIQUE constraint.
|
|
37
|
+
// Keep the claim and entitlement changes in the same database transaction.
|
|
38
|
+
const claimed = await claimWebhookId(webhookId);
|
|
39
|
+
if (!claimed) {
|
|
40
|
+
return NextResponse.json({ received: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
switch (event.type) {
|
|
44
|
+
case 'subscription.active':
|
|
45
|
+
await grantAccess(event.data.customer.customer_id, event.data.product_id);
|
|
46
|
+
break;
|
|
47
|
+
case 'subscription.on_hold':
|
|
48
|
+
await notifyPaymentFailed(event.data.customer.customer_id);
|
|
49
|
+
break;
|
|
50
|
+
case 'subscription.paused':
|
|
51
|
+
case 'subscription.unpaused': {
|
|
52
|
+
// Pause and resume can happen in quick succession, and handlers for
|
|
53
|
+
// different events can finish in any order. Don't trust the event type:
|
|
54
|
+
// re-read the subscription and apply its CURRENT status, scoped to this
|
|
55
|
+
// subscription (the customer may hold other active ones). Run this under
|
|
56
|
+
// a per-subscription lock if your handlers execute concurrently.
|
|
57
|
+
const current = await client.subscriptions.retrieve(event.data.subscription_id);
|
|
58
|
+
if (current.status === 'paused') {
|
|
59
|
+
await suspendSubscriptionAccess(current.subscription_id);
|
|
60
|
+
} else if (current.status === 'active' || current.status === 'past_due') {
|
|
61
|
+
// past_due keeps access during the payment grace period
|
|
62
|
+
await restoreSubscriptionAccess(current.subscription_id);
|
|
63
|
+
}
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case 'subscription.cancelled':
|
|
67
|
+
if (event.data.cancel_at_next_billing_date) {
|
|
68
|
+
await scheduleAccessRevocation(event.data.subscription_id, new Date(event.data.next_billing_date));
|
|
69
|
+
} else {
|
|
70
|
+
await revokeAccessImmediately(event.data.subscription_id);
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
case 'subscription.expired':
|
|
74
|
+
await revokeAccess(event.data.customer.customer_id);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return NextResponse.json({ received: true });
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
@@ -235,122 +235,13 @@ The `product-catalog-management` skill is the canonical source for the complete
|
|
|
235
235
|
|
|
236
236
|
## Instrumenting Your Application
|
|
237
237
|
|
|
238
|
-
|
|
238
|
+
Full guide: [references/instrumentation.md](references/instrumentation.md).
|
|
239
239
|
|
|
240
|
-
|
|
240
|
+
Covers:
|
|
241
241
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
customer_id: string;
|
|
246
|
-
event_name: string;
|
|
247
|
-
timestamp: string;
|
|
248
|
-
metadata: Record<string, string | number | boolean>;
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
interface UsageOutbox {
|
|
252
|
-
persist(event: PersistedUsageEvent): Promise<void>;
|
|
253
|
-
nextBatch(limit: number): Promise<PersistedUsageEvent[]>;
|
|
254
|
-
markIngested(eventIds: string[]): Promise<void>;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
async function completeApiOperation(
|
|
258
|
-
outbox: UsageOutbox,
|
|
259
|
-
operationId: string,
|
|
260
|
-
customerId: string,
|
|
261
|
-
occurredAt: string,
|
|
262
|
-
): Promise<void> {
|
|
263
|
-
await outbox.persist({
|
|
264
|
-
event_id: `api-call:${operationId}`,
|
|
265
|
-
customer_id: customerId,
|
|
266
|
-
event_name: 'api.call',
|
|
267
|
-
timestamp: occurredAt,
|
|
268
|
-
metadata: { endpoint: '/v1/users', method: 'GET', status: 200 },
|
|
269
|
-
});
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
async function ingestUsageOutbox(outbox: UsageOutbox): Promise<void> {
|
|
273
|
-
const events = await outbox.nextBatch(1000);
|
|
274
|
-
if (events.length === 0) return;
|
|
275
|
-
|
|
276
|
-
await client.usageEvents.ingest({ events });
|
|
277
|
-
await outbox.markIngested(events.map((event) => event.event_id));
|
|
278
|
-
}
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
If the worker crashes after Dodo accepts the batch but before `markIngested`, retry the same persisted events with the same IDs. Dodo ignores the already-ingested IDs.
|
|
282
|
-
|
|
283
|
-
### Track AI Token Usage
|
|
284
|
-
|
|
285
|
-
```typescript
|
|
286
|
-
async function callAI(
|
|
287
|
-
customerId: string,
|
|
288
|
-
generationId: string,
|
|
289
|
-
prompt: string,
|
|
290
|
-
completedAt: string,
|
|
291
|
-
) {
|
|
292
|
-
const response = await openai.chat.completions.create({
|
|
293
|
-
model: 'gpt-4',
|
|
294
|
-
messages: [{ role: 'user', content: prompt }],
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
// Track tokens after completion
|
|
298
|
-
await client.usageEvents.ingest({
|
|
299
|
-
events: [{
|
|
300
|
-
event_id: `generation:${generationId}`,
|
|
301
|
-
customer_id: customerId,
|
|
302
|
-
event_name: 'ai.tokens',
|
|
303
|
-
timestamp: completedAt,
|
|
304
|
-
// Aggregated properties (Sum/Max/Last) must be numbers, not strings.
|
|
305
|
-
metadata: {
|
|
306
|
-
tokens: response.usage.total_tokens,
|
|
307
|
-
prompt_tokens: response.usage.prompt_tokens,
|
|
308
|
-
completion_tokens: response.usage.completion_tokens,
|
|
309
|
-
model: 'gpt-4',
|
|
310
|
-
}
|
|
311
|
-
}]
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
return response;
|
|
315
|
-
}
|
|
316
|
-
```
|
|
317
|
-
|
|
318
|
-
### Track Storage Usage
|
|
319
|
-
|
|
320
|
-
For snapshot-based metrics (current state), use the `last` aggregation:
|
|
321
|
-
|
|
322
|
-
```typescript
|
|
323
|
-
async function updateStorageUsage(
|
|
324
|
-
customerId: string,
|
|
325
|
-
snapshotId: string,
|
|
326
|
-
bytesUsed: number,
|
|
327
|
-
capturedAt: string,
|
|
328
|
-
) {
|
|
329
|
-
await client.usageEvents.ingest({
|
|
330
|
-
events: [{
|
|
331
|
-
event_id: `storage-snapshot:${snapshotId}`,
|
|
332
|
-
customer_id: customerId,
|
|
333
|
-
event_name: 'storage.snapshot',
|
|
334
|
-
timestamp: capturedAt,
|
|
335
|
-
// Numeric values: a string property does not aggregate.
|
|
336
|
-
metadata: {
|
|
337
|
-
bytes: bytesUsed,
|
|
338
|
-
gb: Number((bytesUsed / 1024 / 1024 / 1024).toFixed(2)),
|
|
339
|
-
}
|
|
340
|
-
}]
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// Call periodically or after storage changes
|
|
345
|
-
await updateStorageUsage(
|
|
346
|
-
'cus_abc',
|
|
347
|
-
'snapshot_01K1M4D2K9',
|
|
348
|
-
5368709120,
|
|
349
|
-
'2026-08-01T10:30:00Z',
|
|
350
|
-
); // 5GB
|
|
351
|
-
```
|
|
352
|
-
|
|
353
|
-
---
|
|
242
|
+
- Track API Calls
|
|
243
|
+
- Track AI Token Usage
|
|
244
|
+
- Track Storage Usage
|
|
354
245
|
|
|
355
246
|
## Querying Usage for Display
|
|
356
247
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Dodo Payments Usage-Based Billing: Instrumenting Your Application
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Track API Calls
|
|
5
|
+
|
|
6
|
+
Persist the event before reporting the operation as complete, then ingest it from a retrying worker. The outbox or queue implementation must durably store the payload before `persist` resolves.
|
|
7
|
+
|
|
8
|
+
```typescript
|
|
9
|
+
type PersistedUsageEvent = {
|
|
10
|
+
event_id: string;
|
|
11
|
+
customer_id: string;
|
|
12
|
+
event_name: string;
|
|
13
|
+
timestamp: string;
|
|
14
|
+
metadata: Record<string, string | number | boolean>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
interface UsageOutbox {
|
|
18
|
+
persist(event: PersistedUsageEvent): Promise<void>;
|
|
19
|
+
nextBatch(limit: number): Promise<PersistedUsageEvent[]>;
|
|
20
|
+
markIngested(eventIds: string[]): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function completeApiOperation(
|
|
24
|
+
outbox: UsageOutbox,
|
|
25
|
+
operationId: string,
|
|
26
|
+
customerId: string,
|
|
27
|
+
occurredAt: string,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
await outbox.persist({
|
|
30
|
+
event_id: `api-call:${operationId}`,
|
|
31
|
+
customer_id: customerId,
|
|
32
|
+
event_name: 'api.call',
|
|
33
|
+
timestamp: occurredAt,
|
|
34
|
+
metadata: { endpoint: '/v1/users', method: 'GET', status: 200 },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function ingestUsageOutbox(outbox: UsageOutbox): Promise<void> {
|
|
39
|
+
const events = await outbox.nextBatch(1000);
|
|
40
|
+
if (events.length === 0) return;
|
|
41
|
+
|
|
42
|
+
await client.usageEvents.ingest({ events });
|
|
43
|
+
await outbox.markIngested(events.map((event) => event.event_id));
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
If the worker crashes after Dodo accepts the batch but before `markIngested`, retry the same persisted events with the same IDs. Dodo ignores the already-ingested IDs.
|
|
48
|
+
|
|
49
|
+
### Track AI Token Usage
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
async function callAI(
|
|
53
|
+
customerId: string,
|
|
54
|
+
generationId: string,
|
|
55
|
+
prompt: string,
|
|
56
|
+
completedAt: string,
|
|
57
|
+
) {
|
|
58
|
+
const response = await openai.chat.completions.create({
|
|
59
|
+
model: 'gpt-4',
|
|
60
|
+
messages: [{ role: 'user', content: prompt }],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Track tokens after completion
|
|
64
|
+
await client.usageEvents.ingest({
|
|
65
|
+
events: [{
|
|
66
|
+
event_id: `generation:${generationId}`,
|
|
67
|
+
customer_id: customerId,
|
|
68
|
+
event_name: 'ai.tokens',
|
|
69
|
+
timestamp: completedAt,
|
|
70
|
+
// Aggregated properties (Sum/Max/Last) must be numbers, not strings.
|
|
71
|
+
metadata: {
|
|
72
|
+
tokens: response.usage.total_tokens,
|
|
73
|
+
prompt_tokens: response.usage.prompt_tokens,
|
|
74
|
+
completion_tokens: response.usage.completion_tokens,
|
|
75
|
+
model: 'gpt-4',
|
|
76
|
+
}
|
|
77
|
+
}]
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return response;
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Track Storage Usage
|
|
85
|
+
|
|
86
|
+
For snapshot-based metrics (current state), use the `last` aggregation:
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
async function updateStorageUsage(
|
|
90
|
+
customerId: string,
|
|
91
|
+
snapshotId: string,
|
|
92
|
+
bytesUsed: number,
|
|
93
|
+
capturedAt: string,
|
|
94
|
+
) {
|
|
95
|
+
await client.usageEvents.ingest({
|
|
96
|
+
events: [{
|
|
97
|
+
event_id: `storage-snapshot:${snapshotId}`,
|
|
98
|
+
customer_id: customerId,
|
|
99
|
+
event_name: 'storage.snapshot',
|
|
100
|
+
timestamp: capturedAt,
|
|
101
|
+
// Numeric values: a string property does not aggregate.
|
|
102
|
+
metadata: {
|
|
103
|
+
bytes: bytesUsed,
|
|
104
|
+
gb: Number((bytesUsed / 1024 / 1024 / 1024).toFixed(2)),
|
|
105
|
+
}
|
|
106
|
+
}]
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Call periodically or after storage changes
|
|
111
|
+
await updateStorageUsage(
|
|
112
|
+
'cus_abc',
|
|
113
|
+
'snapshot_01K1M4D2K9',
|
|
114
|
+
5368709120,
|
|
115
|
+
'2026-08-01T10:30:00Z',
|
|
116
|
+
); // 5GB
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|