@wopr-network/platform-core 1.14.7 → 1.15.0

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 (127) hide show
  1. package/.env.example +10 -0
  2. package/dist/account/deletion-executor-repository.d.ts +2 -2
  3. package/dist/account/deletion-executor-repository.js +5 -5
  4. package/dist/{monetization/payram → billing/crypto}/cents-credits-boundary.test.js +14 -17
  5. package/dist/billing/{payram → crypto}/charge-store.d.ts +17 -13
  6. package/dist/billing/{payram → crypto}/charge-store.js +21 -18
  7. package/dist/billing/crypto/charge-store.test.js +64 -0
  8. package/dist/billing/crypto/checkout.d.ts +18 -0
  9. package/dist/billing/crypto/checkout.js +35 -0
  10. package/dist/billing/crypto/checkout.test.js +71 -0
  11. package/dist/billing/crypto/client.d.ts +39 -0
  12. package/dist/billing/crypto/client.js +72 -0
  13. package/dist/billing/crypto/client.test.js +100 -0
  14. package/dist/billing/crypto/index.d.ts +9 -0
  15. package/dist/billing/crypto/index.js +5 -0
  16. package/dist/billing/crypto/types.d.ts +61 -0
  17. package/dist/billing/crypto/types.js +24 -0
  18. package/dist/billing/crypto/webhook.d.ts +34 -0
  19. package/dist/billing/crypto/webhook.js +107 -0
  20. package/dist/billing/crypto/webhook.test.js +266 -0
  21. package/dist/billing/index.d.ts +1 -1
  22. package/dist/billing/index.js +2 -2
  23. package/dist/billing/payment-processor.d.ts +3 -3
  24. package/dist/config/provider-endpoints.d.ts +4 -0
  25. package/dist/config/provider-endpoints.js +10 -6
  26. package/dist/credits/credit-ledger.d.ts +3 -3
  27. package/dist/credits/credit-ledger.js +3 -3
  28. package/dist/db/index.d.ts +1 -1
  29. package/dist/db/index.js +1 -1
  30. package/dist/db/schema/credits.js +1 -1
  31. package/dist/db/schema/{payram.d.ts → crypto.d.ts} +17 -13
  32. package/dist/db/schema/crypto.js +25 -0
  33. package/dist/db/schema/index.d.ts +1 -1
  34. package/dist/db/schema/index.js +1 -1
  35. package/dist/fleet/node-repository.d.ts +1 -3
  36. package/dist/monetization/crypto/__tests__/webhook.test.js +249 -0
  37. package/dist/monetization/crypto/index.d.ts +4 -0
  38. package/dist/monetization/crypto/index.js +2 -0
  39. package/dist/monetization/crypto/webhook.d.ts +24 -0
  40. package/dist/monetization/crypto/webhook.js +88 -0
  41. package/dist/monetization/index.d.ts +3 -3
  42. package/dist/monetization/index.js +1 -1
  43. package/dist/monetization/repository-types.d.ts +1 -1
  44. package/dist/observability/pagerduty.test.js +1 -0
  45. package/dist/security/key-validation.test.js +65 -8
  46. package/drizzle/migrations/0004_crypto_charges.sql +25 -0
  47. package/drizzle/migrations/meta/_journal.json +7 -0
  48. package/package.json +1 -3
  49. package/src/account/deletion-executor-repository.ts +6 -6
  50. package/src/billing/{payram → crypto}/cents-credits-boundary.test.ts +14 -17
  51. package/src/billing/crypto/charge-store.test.ts +81 -0
  52. package/src/billing/{payram → crypto}/charge-store.ts +28 -25
  53. package/src/billing/crypto/checkout.test.ts +93 -0
  54. package/src/billing/crypto/checkout.ts +48 -0
  55. package/src/billing/crypto/client.test.ts +132 -0
  56. package/src/billing/crypto/client.ts +86 -0
  57. package/src/billing/crypto/index.ts +15 -0
  58. package/src/billing/crypto/types.ts +83 -0
  59. package/src/billing/crypto/webhook.test.ts +340 -0
  60. package/src/billing/crypto/webhook.ts +136 -0
  61. package/src/billing/index.ts +2 -2
  62. package/src/billing/payment-processor.ts +3 -3
  63. package/src/config/provider-endpoints.ts +10 -6
  64. package/src/credits/credit-ledger.ts +3 -3
  65. package/src/db/index.ts +1 -2
  66. package/src/db/schema/credits.ts +1 -1
  67. package/src/db/schema/crypto.ts +30 -0
  68. package/src/db/schema/index.ts +1 -1
  69. package/src/fleet/node-repository.ts +8 -3
  70. package/src/monetization/crypto/__tests__/webhook.test.ts +327 -0
  71. package/src/monetization/crypto/index.ts +23 -0
  72. package/src/monetization/crypto/webhook.ts +115 -0
  73. package/src/monetization/index.ts +23 -21
  74. package/src/monetization/repository-types.ts +2 -2
  75. package/src/observability/pagerduty.test.ts +1 -0
  76. package/src/security/key-validation.test.ts +74 -8
  77. package/dist/billing/payram/cents-credits-boundary.test.js +0 -75
  78. package/dist/billing/payram/charge-store.test.js +0 -64
  79. package/dist/billing/payram/checkout.d.ts +0 -15
  80. package/dist/billing/payram/checkout.js +0 -24
  81. package/dist/billing/payram/checkout.test.js +0 -74
  82. package/dist/billing/payram/client.d.ts +0 -7
  83. package/dist/billing/payram/client.js +0 -15
  84. package/dist/billing/payram/client.test.js +0 -52
  85. package/dist/billing/payram/index.d.ts +0 -8
  86. package/dist/billing/payram/index.js +0 -4
  87. package/dist/billing/payram/types.d.ts +0 -40
  88. package/dist/billing/payram/webhook.d.ts +0 -19
  89. package/dist/billing/payram/webhook.js +0 -71
  90. package/dist/billing/payram/webhook.test.d.ts +0 -7
  91. package/dist/billing/payram/webhook.test.js +0 -249
  92. package/dist/db/schema/payram.js +0 -21
  93. package/dist/monetization/payram/charge-store.test.d.ts +0 -1
  94. package/dist/monetization/payram/charge-store.test.js +0 -64
  95. package/dist/monetization/payram/checkout.test.d.ts +0 -1
  96. package/dist/monetization/payram/checkout.test.js +0 -73
  97. package/dist/monetization/payram/client.test.d.ts +0 -1
  98. package/dist/monetization/payram/client.test.js +0 -52
  99. package/dist/monetization/payram/index.d.ts +0 -4
  100. package/dist/monetization/payram/index.js +0 -2
  101. package/dist/monetization/payram/webhook.d.ts +0 -17
  102. package/dist/monetization/payram/webhook.js +0 -71
  103. package/dist/monetization/payram/webhook.test.d.ts +0 -7
  104. package/dist/monetization/payram/webhook.test.js +0 -247
  105. package/src/billing/payram/charge-store.test.ts +0 -84
  106. package/src/billing/payram/checkout.test.ts +0 -99
  107. package/src/billing/payram/checkout.ts +0 -40
  108. package/src/billing/payram/client.test.ts +0 -62
  109. package/src/billing/payram/client.ts +0 -21
  110. package/src/billing/payram/index.ts +0 -14
  111. package/src/billing/payram/types.ts +0 -44
  112. package/src/billing/payram/webhook.test.ts +0 -320
  113. package/src/billing/payram/webhook.ts +0 -94
  114. package/src/db/schema/payram.ts +0 -26
  115. package/src/monetization/payram/cents-credits-boundary.test.ts +0 -84
  116. package/src/monetization/payram/charge-store.test.ts +0 -84
  117. package/src/monetization/payram/checkout.test.ts +0 -98
  118. package/src/monetization/payram/client.test.ts +0 -62
  119. package/src/monetization/payram/index.ts +0 -20
  120. package/src/monetization/payram/webhook.test.ts +0 -327
  121. package/src/monetization/payram/webhook.ts +0 -97
  122. /package/dist/billing/{payram → crypto}/cents-credits-boundary.test.d.ts +0 -0
  123. /package/dist/billing/{payram → crypto}/charge-store.test.d.ts +0 -0
  124. /package/dist/billing/{payram → crypto}/checkout.test.d.ts +0 -0
  125. /package/dist/billing/{payram → crypto}/client.test.d.ts +0 -0
  126. /package/dist/billing/{payram/types.js → crypto/webhook.test.d.ts} +0 -0
  127. /package/dist/monetization/{payram/cents-credits-boundary.test.d.ts → crypto/__tests__/webhook.test.d.ts} +0 -0
@@ -0,0 +1,88 @@
1
+ import { mapBtcPayEventToStatus } from "@wopr-network/platform-core/billing";
2
+ import { Credit } from "@wopr-network/platform-core/credits";
3
+ /**
4
+ * Process a BTCPay Server webhook event (WOPR-specific version).
5
+ *
6
+ * Only credits the ledger on InvoiceSettled.
7
+ * Uses botBilling.checkReactivation for WOPR bot suspension recovery.
8
+ *
9
+ * Idempotency strategy (matches Stripe webhook pattern):
10
+ * Primary: `creditLedger.hasReferenceId("crypto:<invoiceId>")` — atomic,
11
+ * checked inside the ledger's serialized transaction.
12
+ * Secondary: `chargeStore.markCredited()` — advisory flag for queries.
13
+ *
14
+ * CRITICAL: charge.amountUsdCents is in USD cents (integer).
15
+ * Credit.fromCents() converts cents → nanodollars for the ledger.
16
+ */
17
+ export async function handleCryptoWebhook(deps, payload) {
18
+ const { chargeStore, creditLedger } = deps;
19
+ // Replay guard FIRST: deduplicate by invoiceId + event type.
20
+ // Must run before mapBtcPayEventToStatus() — unknown event types throw,
21
+ // and BTCPay retries webhooks on failure. Without this ordering, an unknown
22
+ // event type causes an infinite retry loop.
23
+ const dedupeKey = `${payload.invoiceId}:${payload.type}`;
24
+ if (await deps.replayGuard.isDuplicate(dedupeKey, "crypto")) {
25
+ return { handled: true, status: "New", duplicate: true };
26
+ }
27
+ // Map BTCPay event type to a CryptoPaymentState (throws on unknown types).
28
+ const status = mapBtcPayEventToStatus(payload.type);
29
+ // Look up the charge record to find the tenant.
30
+ const charge = await chargeStore.getByReferenceId(payload.invoiceId);
31
+ if (!charge) {
32
+ return { handled: false, status };
33
+ }
34
+ // Update charge status regardless of event type.
35
+ await chargeStore.updateStatus(payload.invoiceId, status);
36
+ let result;
37
+ if (payload.type === "InvoiceSettled") {
38
+ // Idempotency: use ledger referenceId check (same pattern as Stripe webhook).
39
+ // This is atomic — the referenceId is checked inside the ledger's serialized
40
+ // transaction, eliminating the TOCTOU race of isCredited() + creditLedger().
41
+ const creditRef = `crypto:${payload.invoiceId}`;
42
+ if (await creditLedger.hasReferenceId(creditRef)) {
43
+ result = {
44
+ handled: true,
45
+ status,
46
+ tenant: charge.tenantId,
47
+ creditedCents: 0,
48
+ };
49
+ }
50
+ else {
51
+ // Credit the original USD amount requested (not the crypto amount).
52
+ // charge.amountUsdCents is in USD cents (integer).
53
+ // Credit.fromCents() converts to nanodollars for the ledger.
54
+ const creditCents = charge.amountUsdCents;
55
+ await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
56
+ description: `Crypto credit purchase via BTCPay (invoice: ${payload.invoiceId})`,
57
+ referenceId: creditRef,
58
+ fundingSource: "crypto",
59
+ });
60
+ // Mark credited (advisory — primary idempotency is the ledger referenceId above).
61
+ await chargeStore.markCredited(payload.invoiceId);
62
+ // Reactivate suspended bots (same as Stripe webhook).
63
+ let reactivatedBots;
64
+ if (deps.botBilling) {
65
+ reactivatedBots = await deps.botBilling.checkReactivation(charge.tenantId, creditLedger);
66
+ if (reactivatedBots.length === 0)
67
+ reactivatedBots = undefined;
68
+ }
69
+ result = {
70
+ handled: true,
71
+ status,
72
+ tenant: charge.tenantId,
73
+ creditedCents: creditCents,
74
+ reactivatedBots,
75
+ };
76
+ }
77
+ }
78
+ else {
79
+ // New, Processing, Expired, Invalid — just track status.
80
+ result = {
81
+ handled: true,
82
+ status,
83
+ tenant: charge.tenantId,
84
+ };
85
+ }
86
+ await deps.replayGuard.markSeen(dedupeKey, "crypto");
87
+ return result;
88
+ }
@@ -41,14 +41,14 @@ export type { BudgetCheckerConfig, BudgetCheckResult, SpendLimits } from "./budg
41
41
  export { BudgetChecker, DrizzleBudgetChecker } from "./budget/index.js";
42
42
  export type { BillingState, CreditType, DebitType, GetActiveBotCount, HistoryOptions, ILedger, JournalEntry, OnSuspend, RuntimeCronConfig, RuntimeCronResult, TransactionType, } from "./credits/index.js";
43
43
  export { BotBilling, buildResourceTierCosts, DAILY_BOT_COST, DrizzleBotBilling, DrizzleLedger, grantSignupCredits, InsufficientBalanceError, Ledger, runRuntimeDeductions, SIGNUP_GRANT, SUSPENSION_GRACE_DAYS, } from "./credits/index.js";
44
+ export type { CryptoBillingConfig, CryptoCheckoutOpts, CryptoConfig, CryptoPaymentState, CryptoWebhookDeps, CryptoWebhookPayload, CryptoWebhookResult, } from "./crypto/index.js";
45
+ export { BTCPayClient, CryptoChargeRepository, createCryptoCheckout, DrizzleCryptoChargeRepository, handleCryptoWebhook, loadCryptoConfig, MIN_PAYMENT_USD, mapBtcPayEventToStatus, verifyCryptoWebhookSignature, } from "./crypto/index.js";
44
46
  export { type CreditGateConfig, createBalanceGate, createCreditGate, createFeatureGate, type FeatureGateConfig, type GetUserBalance, type ResolveTenantId, } from "./feature-gate.js";
45
47
  export type { BillingPeriod, BillingPeriodSummary, MeterEventRow, UsageSummary, } from "./metering/index.js";
46
48
  export { DrizzleMeterAggregator, DrizzleMeterEmitter, MeterAggregator, MeterEmitter, } from "./metering/index.js";
47
- export type { PayRamBillingConfig, PayRamCheckoutOpts, PayRamConfig, PayRamPaymentState, PayRamWebhookDeps, PayRamWebhookPayload, PayRamWebhookResult, } from "./payram/index.js";
48
- export { createPayRamCheckout, createPayRamClient, DrizzlePayRamChargeRepository, handlePayRamWebhook, loadPayRamConfig, MIN_PAYMENT_USD, PayRamChargeRepository, } from "./payram/index.js";
49
49
  export { checkInstanceQuota, DEFAULT_INSTANCE_LIMITS, type InstanceLimits, type QuotaCheckResult, } from "./quotas/quota-check.js";
50
50
  export { buildResourceLimits, type ContainerResourceLimits, DEFAULT_RESOURCE_CONFIG, type ResourceConfig, } from "./quotas/resource-limits.js";
51
- export type { IBotBilling, IBudgetChecker, IMeterAggregator, IMeterEmitter, IPayRamChargeRepository, ITenantCustomerRepository, PayRamChargeRecord, } from "./repository-types.js";
51
+ export type { CryptoChargeRecord, IBotBilling, IBudgetChecker, ICryptoChargeRepository, IMeterAggregator, IMeterEmitter, ITenantCustomerRepository, } from "./repository-types.js";
52
52
  export { AdapterSocket, type SocketConfig, type SocketRequest } from "./socket/socket.js";
53
53
  export type { CreditCheckoutOpts, CreditPriceMap, CreditPricePoint, PortalSessionOpts, StripeBillingConfig, TenantCustomerRow, WebhookDeps, WebhookResult, } from "./stripe/index.js";
54
54
  export { CREDIT_PRICE_POINTS, createCreditCheckoutSession, createPortalSession, createStripeClient, DrizzleTenantCustomerRepository, getConfiguredPriceIds, getCreditAmountForPurchase, handleWebhookEvent, loadCreditPriceMap, loadStripeConfig, lookupCreditPrice, TenantCustomerRepository, } from "./stripe/index.js";
@@ -50,10 +50,10 @@ export { withMargin, } from "./adapters/types.js";
50
50
  export { ArbitrageRouter, NoProviderAvailableError, ProviderRegistry, } from "./arbitrage/index.js";
51
51
  export { BudgetChecker, DrizzleBudgetChecker } from "./budget/index.js";
52
52
  export { BotBilling, buildResourceTierCosts, DAILY_BOT_COST, DrizzleBotBilling, DrizzleLedger, grantSignupCredits, InsufficientBalanceError, Ledger, runRuntimeDeductions, SIGNUP_GRANT, SUSPENSION_GRACE_DAYS, } from "./credits/index.js";
53
+ export { BTCPayClient, CryptoChargeRepository, createCryptoCheckout, DrizzleCryptoChargeRepository, handleCryptoWebhook, loadCryptoConfig, MIN_PAYMENT_USD, mapBtcPayEventToStatus, verifyCryptoWebhookSignature, } from "./crypto/index.js";
53
54
  // Feature gating middleware (WOP-384 — replaced tier gates with balance gates)
54
55
  export { createBalanceGate, createCreditGate, createFeatureGate, } from "./feature-gate.js";
55
56
  export { DrizzleMeterAggregator, DrizzleMeterEmitter, MeterAggregator, MeterEmitter, } from "./metering/index.js";
56
- export { createPayRamCheckout, createPayRamClient, DrizzlePayRamChargeRepository, handlePayRamWebhook, loadPayRamConfig, MIN_PAYMENT_USD, PayRamChargeRepository, } from "./payram/index.js";
57
57
  export { checkInstanceQuota, DEFAULT_INSTANCE_LIMITS, } from "./quotas/quota-check.js";
58
58
  export { buildResourceLimits, DEFAULT_RESOURCE_CONFIG, } from "./quotas/resource-limits.js";
59
59
  // Socket layer — adapter orchestrator (WOP-376)
@@ -1,4 +1,4 @@
1
- export type { IPayRamChargeRepository, ITenantCustomerRepository, PayRamChargeRecord, } from "@wopr-network/platform-core/billing";
1
+ export type { CryptoChargeRecord, ICryptoChargeRepository, ITenantCustomerRepository, } from "@wopr-network/platform-core/billing";
2
2
  export type { IAutoTopupSettingsRepository, ILedger } from "@wopr-network/platform-core/credits";
3
3
  export type { IMeterAggregator, IMeterEmitter } from "@wopr-network/platform-core/metering";
4
4
  export type { FraudEvent, FraudEventInput, IAffiliateFraudRepository } from "./affiliate/affiliate-fraud-repository.js";
@@ -5,6 +5,7 @@ describe("PagerDutyNotifier", () => {
5
5
  vi.stubGlobal("fetch", vi.fn());
6
6
  });
7
7
  afterEach(() => {
8
+ vi.useRealTimers();
8
9
  vi.restoreAllMocks();
9
10
  });
10
11
  it("no-ops when disabled", async () => {
@@ -1,15 +1,20 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { PROVIDER_API_URLS } from "../config/provider-endpoints.js";
3
2
  import { PROVIDER_ENDPOINTS, validateProviderKey } from "./key-validation.js";
4
3
  describe("key-validation", () => {
5
4
  describe("PROVIDER_API_URLS", () => {
6
- it("exports a URL for every supported provider", () => {
7
- expect(PROVIDER_API_URLS.anthropic).toBe("https://api.anthropic.com/v1/models");
8
- expect(PROVIDER_API_URLS.openai).toBe("https://api.openai.com/v1/models");
9
- expect(PROVIDER_API_URLS.google).toBe("https://generativelanguage.googleapis.com/v1/models");
10
- expect(PROVIDER_API_URLS.discord).toBe("https://discord.com/api/v10/users/@me");
11
- expect(PROVIDER_API_URLS.elevenlabs).toBe("https://api.elevenlabs.io/v1/user");
12
- expect(PROVIDER_API_URLS.deepgram).toBe("https://api.deepgram.com/v1/projects");
5
+ afterEach(() => {
6
+ vi.unstubAllEnvs();
7
+ vi.resetModules();
8
+ });
9
+ it("exports a URL for every supported provider", async () => {
10
+ vi.resetModules();
11
+ const { PROVIDER_API_URLS: urls } = await import("../config/provider-endpoints.js");
12
+ expect(urls.anthropic).toBe("https://api.anthropic.com/v1/models");
13
+ expect(urls.openai).toBe("https://api.openai.com/v1/models");
14
+ expect(urls.google).toBe("https://generativelanguage.googleapis.com/v1/models");
15
+ expect(urls.discord).toBe("https://discord.com/api/v10/users/@me");
16
+ expect(urls.elevenlabs).toBe("https://api.elevenlabs.io/v1/user");
17
+ expect(urls.deepgram).toBe("https://api.deepgram.com/v1/projects");
13
18
  });
14
19
  });
15
20
  describe("PROVIDER_ENDPOINTS", () => {
@@ -84,4 +89,56 @@ describe("key-validation", () => {
84
89
  }));
85
90
  });
86
91
  });
92
+ describe("PROVIDER_API_URLS env overrides", () => {
93
+ afterEach(() => {
94
+ vi.unstubAllEnvs();
95
+ vi.resetModules();
96
+ });
97
+ it("uses ANTHROPIC_API_URL when set", async () => {
98
+ vi.stubEnv("ANTHROPIC_API_URL", "https://custom-anthropic.example.com/v1/models");
99
+ vi.resetModules();
100
+ const { PROVIDER_API_URLS: urls } = await import("../config/provider-endpoints.js");
101
+ expect(urls.anthropic).toBe("https://custom-anthropic.example.com/v1/models");
102
+ });
103
+ it("uses OPENAI_API_URL when set", async () => {
104
+ vi.stubEnv("OPENAI_API_URL", "https://custom-openai.example.com/v1/models");
105
+ vi.resetModules();
106
+ const { PROVIDER_API_URLS: urls } = await import("../config/provider-endpoints.js");
107
+ expect(urls.openai).toBe("https://custom-openai.example.com/v1/models");
108
+ });
109
+ it("uses GOOGLE_API_URL when set", async () => {
110
+ vi.stubEnv("GOOGLE_API_URL", "https://custom-google.example.com/v1/models");
111
+ vi.resetModules();
112
+ const { PROVIDER_API_URLS: urls } = await import("../config/provider-endpoints.js");
113
+ expect(urls.google).toBe("https://custom-google.example.com/v1/models");
114
+ });
115
+ it("uses DISCORD_API_URL when set", async () => {
116
+ vi.stubEnv("DISCORD_API_URL", "https://custom-discord.example.com/api/v10/users/@me");
117
+ vi.resetModules();
118
+ const { PROVIDER_API_URLS: urls } = await import("../config/provider-endpoints.js");
119
+ expect(urls.discord).toBe("https://custom-discord.example.com/api/v10/users/@me");
120
+ });
121
+ it("uses ELEVENLABS_API_URL when set", async () => {
122
+ vi.stubEnv("ELEVENLABS_API_URL", "https://custom-elevenlabs.example.com/v1/user");
123
+ vi.resetModules();
124
+ const { PROVIDER_API_URLS: urls } = await import("../config/provider-endpoints.js");
125
+ expect(urls.elevenlabs).toBe("https://custom-elevenlabs.example.com/v1/user");
126
+ });
127
+ it("uses DEEPGRAM_API_URL when set", async () => {
128
+ vi.stubEnv("DEEPGRAM_API_URL", "https://custom-deepgram.example.com/v1/projects");
129
+ vi.resetModules();
130
+ const { PROVIDER_API_URLS: urls } = await import("../config/provider-endpoints.js");
131
+ expect(urls.deepgram).toBe("https://custom-deepgram.example.com/v1/projects");
132
+ });
133
+ it("falls back to defaults when env vars are not set", async () => {
134
+ vi.resetModules();
135
+ const { PROVIDER_API_URLS: urls } = await import("../config/provider-endpoints.js");
136
+ expect(urls.anthropic).toBe("https://api.anthropic.com/v1/models");
137
+ expect(urls.openai).toBe("https://api.openai.com/v1/models");
138
+ expect(urls.google).toBe("https://generativelanguage.googleapis.com/v1/models");
139
+ expect(urls.discord).toBe("https://discord.com/api/v10/users/@me");
140
+ expect(urls.elevenlabs).toBe("https://api.elevenlabs.io/v1/user");
141
+ expect(urls.deepgram).toBe("https://api.deepgram.com/v1/projects");
142
+ });
143
+ });
87
144
  });
@@ -0,0 +1,25 @@
1
+ -- Replace payram_charges with crypto_charges (BTCPay Server).
2
+ -- payram_charges existed only in the initial schema (0000) and was never used
3
+ -- in production — no data migration is needed. The table is dropped and replaced
4
+ -- with crypto_charges which has the same column structure but uses BTCPay-specific
5
+ -- naming (reference_id = BTCPay invoice ID).
6
+ --> statement-breakpoint
7
+ DROP TABLE IF EXISTS "payram_charges";
8
+ --> statement-breakpoint
9
+ CREATE TABLE IF NOT EXISTS "crypto_charges" (
10
+ "reference_id" text PRIMARY KEY NOT NULL,
11
+ "tenant_id" text NOT NULL,
12
+ "amount_usd_cents" integer NOT NULL,
13
+ "status" text DEFAULT 'New' NOT NULL,
14
+ "currency" text,
15
+ "filled_amount" text,
16
+ "created_at" text DEFAULT (now()) NOT NULL,
17
+ "updated_at" text DEFAULT (now()) NOT NULL,
18
+ "credited_at" text
19
+ );
20
+ --> statement-breakpoint
21
+ CREATE INDEX IF NOT EXISTS "idx_crypto_charges_tenant" ON "crypto_charges" USING btree ("tenant_id");
22
+ --> statement-breakpoint
23
+ CREATE INDEX IF NOT EXISTS "idx_crypto_charges_status" ON "crypto_charges" USING btree ("status");
24
+ --> statement-breakpoint
25
+ CREATE INDEX IF NOT EXISTS "idx_crypto_charges_created" ON "crypto_charges" USING btree ("created_at");
@@ -29,6 +29,13 @@
29
29
  "when": 1741881600000,
30
30
  "tag": "0003_double_entry_ledger",
31
31
  "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "7",
36
+ "when": 1741968000000,
37
+ "tag": "0004_crypto_charges",
38
+ "breakpoints": true
32
39
  }
33
40
  ]
34
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.14.7",
3
+ "version": "1.15.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -87,7 +87,6 @@
87
87
  "dockerode": ">=4",
88
88
  "drizzle-orm": ">=0.45",
89
89
  "hono": ">=4",
90
- "payram": ">=1",
91
90
  "pg": ">=8",
92
91
  "resend": ">=4",
93
92
  "stripe": ">=17",
@@ -111,7 +110,6 @@
111
110
  "drizzle-orm": "^0.45.1",
112
111
  "hono": "^4.12.7",
113
112
  "lru-cache": "^11.2.6",
114
- "payram": "^1.0.1",
115
113
  "pg": "^8.20.0",
116
114
  "resend": "^6.9.3",
117
115
  "stripe": "^20.4.1",
@@ -10,11 +10,11 @@ import {
10
10
  botInstances,
11
11
  creditBalances,
12
12
  creditTransactions,
13
+ cryptoCharges,
13
14
  emailNotifications,
14
15
  meterEvents,
15
16
  notificationPreferences,
16
17
  notificationQueue,
17
- payramCharges,
18
18
  snapshots,
19
19
  stripeUsageReports,
20
20
  tenantCustomers,
@@ -55,7 +55,7 @@ export interface IDeletionExecutorRepository {
55
55
  listSnapshotS3Keys(tenantId: string): Promise<{ id: string; s3Key: string | null }[]>;
56
56
  deleteSnapshots(tenantId: string): Promise<number>;
57
57
  deleteBackupStatus(tenantId: string): Promise<number>;
58
- deletePayramCharges(tenantId: string): Promise<number>;
58
+ deleteCryptoCharges(tenantId: string): Promise<number>;
59
59
  deleteTenantStatus(tenantId: string): Promise<number>;
60
60
  deleteUserRolesByUser(tenantId: string): Promise<number>;
61
61
  deleteUserRolesByTenant(tenantId: string): Promise<number>;
@@ -259,11 +259,11 @@ export class DrizzleDeletionExecutorRepository implements IDeletionExecutorRepos
259
259
  return result.length;
260
260
  }
261
261
 
262
- async deletePayramCharges(tenantId: string): Promise<number> {
262
+ async deleteCryptoCharges(tenantId: string): Promise<number> {
263
263
  const result = await this.db
264
- .delete(payramCharges)
265
- .where(eq(payramCharges.tenantId, tenantId))
266
- .returning({ referenceId: payramCharges.referenceId });
264
+ .delete(cryptoCharges)
265
+ .where(eq(cryptoCharges.tenantId, tenantId))
266
+ .returning({ referenceId: cryptoCharges.referenceId });
267
267
  return result.length;
268
268
  }
269
269
 
@@ -1,20 +1,21 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { Credit } from "../../credits/credit.js";
2
3
 
3
4
  /**
4
- * Regression tests for PayRam cents/credits boundary (WOP-1058).
5
+ * Regression tests for crypto cents/credits boundary.
5
6
  *
6
7
  * Verifies that the USD-to-cents conversion in checkout and
7
8
  * the cents-to-credits flow in the webhook maintain correct units.
8
9
  *
9
10
  * If any of these tests fail after a rename/refactor, a _cents field was
10
11
  * incorrectly changed to store Credit raw units (nanodollars) instead of
11
- * USD cents. See src/monetization/credits/credit-ledger.ts for naming convention.
12
+ * USD cents. See src/credits/credit-ledger.ts for naming convention.
12
13
  */
13
- describe("WOP-1058: PayRam cents/credits boundary", () => {
14
- it("USD to cents conversion is correct (mirrors checkout.ts pattern)", () => {
15
- // This mirrors the conversion in payram/checkout.ts: Math.round(opts.amountUsd * 100)
14
+ describe("Crypto cents/credits boundary", () => {
15
+ it("USD to cents conversion is correct (mirrors checkout.ts Credit.fromDollars pattern)", () => {
16
+ // This mirrors the conversion in crypto/checkout.ts: Credit.fromDollars(amountUsd).toCentsRounded()
16
17
  const amountUsd = 25;
17
- const amountUsdCents = Math.round(amountUsd * 100);
18
+ const amountUsdCents = Credit.fromDollars(amountUsd).toCentsRounded();
18
19
  expect(amountUsdCents).toBe(2500);
19
20
  // Must NOT be nanodollar scale
20
21
  expect(amountUsdCents).toBeLessThan(1_000_000);
@@ -22,7 +23,7 @@ describe("WOP-1058: PayRam cents/credits boundary", () => {
22
23
 
23
24
  it("minimum payment amount converts to valid cents", () => {
24
25
  const MIN_PAYMENT_USD = 10;
25
- const cents = Math.round(MIN_PAYMENT_USD * 100);
26
+ const cents = Credit.fromDollars(MIN_PAYMENT_USD).toCentsRounded();
26
27
  expect(cents).toBe(1000);
27
28
  expect(Number.isInteger(cents)).toBe(true);
28
29
  // Sanity: $10 is 1000 cents, NOT 10_000_000_000 nanodollars
@@ -30,16 +31,13 @@ describe("WOP-1058: PayRam cents/credits boundary", () => {
30
31
  });
31
32
 
32
33
  it("fractional USD amounts round correctly to cents", () => {
33
- // Edge case: floating point conversion
34
34
  const amountUsd = 10.99;
35
- const cents = Math.round(amountUsd * 100);
35
+ const cents = Credit.fromDollars(amountUsd).toCentsRounded();
36
36
  expect(cents).toBe(1099);
37
37
  expect(cents).toBeLessThan(1_000_000);
38
38
  });
39
39
 
40
40
  it("amountUsdCents stored in charge record equals USD * 100 (not nanodollars)", () => {
41
- // The core invariant: payram/checkout.ts stores Math.round(amountUsd * 100)
42
- // as amountUsdCents. This test proves the conversion stays at cent scale.
43
41
  const testCases: Array<{ usd: number; expectedCents: number }> = [
44
42
  { usd: 10, expectedCents: 1000 },
45
43
  { usd: 25, expectedCents: 2500 },
@@ -48,18 +46,18 @@ describe("WOP-1058: PayRam cents/credits boundary", () => {
48
46
  ];
49
47
 
50
48
  for (const { usd, expectedCents } of testCases) {
51
- const amountUsdCents = Math.round(usd * 100);
49
+ const amountUsdCents = Credit.fromDollars(usd).toCentsRounded();
52
50
  expect(amountUsdCents).toBe(expectedCents);
53
51
  // CREDIT SCALE = 1_000_000_000. If this value approaches that, unit confusion occurred.
54
52
  expect(amountUsdCents).toBeLessThan(1_000_000);
55
53
  }
56
54
  });
57
55
 
58
- it("creditedCents in webhook equals amountUsdCents from charge store (1:1 for PayRam)", () => {
59
- // payram/webhook.ts: const creditCents = charge.amountUsdCents;
60
- // The credited amount always equals the stored USD cents — no bonus tiers for PayRam.
56
+ it("creditedCents in webhook equals amountUsdCents from charge store (1:1 for crypto)", () => {
57
+ // crypto/webhook.ts: const creditCents = charge.amountUsdCents;
58
+ // The credited amount always equals the stored USD cents — no bonus tiers for crypto.
61
59
  const chargeAmountUsdCents = 2500; // $25.00
62
- const creditCents = chargeAmountUsdCents; // 1:1 for PayRam
60
+ const creditCents = chargeAmountUsdCents; // 1:1 for crypto
63
61
  expect(creditCents).toBe(2500);
64
62
  // creditedCents must be 2500 (cents), not 25_000_000_000 (nanodollars)
65
63
  expect(creditCents).toBeLessThan(1_000_000);
@@ -68,7 +66,6 @@ describe("WOP-1058: PayRam cents/credits boundary", () => {
68
66
  it("cents-to-nanodollar scale difference is preserved as a sanity constant", () => {
69
67
  // Credit.SCALE = 1_000_000_000 nanodollars per dollar
70
68
  // 1 USD cent = 10_000_000 nanodollars (SCALE / 100)
71
- // This test documents the relationship so future developers understand the gap.
72
69
  const CREDIT_SCALE = 1_000_000_000;
73
70
  const CENTS_PER_DOLLAR = 100;
74
71
  const NANODOLLARS_PER_CENT = CREDIT_SCALE / CENTS_PER_DOLLAR;
@@ -0,0 +1,81 @@
1
+ import type { PGlite } from "@electric-sql/pglite";
2
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
3
+ import type { PlatformDb } from "../../db/index.js";
4
+ import { beginTestTransaction, createTestDb, endTestTransaction, rollbackTestTransaction } from "../../test/db.js";
5
+ import { CryptoChargeRepository } from "./charge-store.js";
6
+
7
+ describe("CryptoChargeRepository", () => {
8
+ let pool: PGlite;
9
+ let db: PlatformDb;
10
+ let store: CryptoChargeRepository;
11
+
12
+ beforeAll(async () => {
13
+ ({ db, pool } = await createTestDb());
14
+ await beginTestTransaction(pool);
15
+ });
16
+
17
+ afterAll(async () => {
18
+ await endTestTransaction(pool);
19
+ await pool.close();
20
+ });
21
+
22
+ beforeEach(async () => {
23
+ await rollbackTestTransaction(pool);
24
+ store = new CryptoChargeRepository(db);
25
+ });
26
+
27
+ it("create() stores a charge with New status", async () => {
28
+ await store.create("inv-001", "tenant-1", 2500);
29
+
30
+ const charge = await store.getByReferenceId("inv-001");
31
+ expect(charge).not.toBeNull();
32
+ expect(charge?.referenceId).toBe("inv-001");
33
+ expect(charge?.tenantId).toBe("tenant-1");
34
+ expect(charge?.amountUsdCents).toBe(2500);
35
+ expect(charge?.status).toBe("New");
36
+ expect(charge?.creditedAt).toBeNull();
37
+ });
38
+
39
+ it("getByReferenceId() returns null when not found", async () => {
40
+ const charge = await store.getByReferenceId("inv-nonexistent");
41
+ expect(charge).toBeNull();
42
+ });
43
+
44
+ it("updateStatus() updates status, currency and filled_amount", async () => {
45
+ await store.create("inv-002", "tenant-2", 5000);
46
+ await store.updateStatus("inv-002", "Settled", "BTC", "0.00025");
47
+
48
+ const charge = await store.getByReferenceId("inv-002");
49
+ expect(charge?.status).toBe("Settled");
50
+ expect(charge?.currency).toBe("BTC");
51
+ expect(charge?.filledAmount).toBe("0.00025");
52
+ });
53
+
54
+ it("updateStatus() handles partial updates (no currency)", async () => {
55
+ await store.create("inv-003", "tenant-3", 1000);
56
+ await store.updateStatus("inv-003", "Processing");
57
+
58
+ const charge = await store.getByReferenceId("inv-003");
59
+ expect(charge?.status).toBe("Processing");
60
+ expect(charge?.currency).toBeNull();
61
+ });
62
+
63
+ it("isCredited() returns false before markCredited", async () => {
64
+ await store.create("inv-004", "tenant-4", 1500);
65
+ expect(await store.isCredited("inv-004")).toBe(false);
66
+ });
67
+
68
+ it("markCredited() sets creditedAt", async () => {
69
+ await store.create("inv-005", "tenant-5", 3000);
70
+ await store.markCredited("inv-005");
71
+
72
+ const charge = await store.getByReferenceId("inv-005");
73
+ expect(charge?.creditedAt).not.toBeNull();
74
+ });
75
+
76
+ it("isCredited() returns true after markCredited", async () => {
77
+ await store.create("inv-006", "tenant-6", 2000);
78
+ await store.markCredited("inv-006");
79
+ expect(await store.isCredited("inv-006")).toBe(true);
80
+ });
81
+ });
@@ -1,9 +1,9 @@
1
1
  import { eq, sql } from "drizzle-orm";
2
2
  import type { PlatformDb } from "../../db/index.js";
3
- import { payramCharges } from "../../db/schema/payram.js";
4
- import type { PayRamPaymentState } from "./types.js";
3
+ import { cryptoCharges } from "../../db/schema/crypto.js";
4
+ import type { CryptoPaymentState } from "./types.js";
5
5
 
6
- export interface PayRamChargeRecord {
6
+ export interface CryptoChargeRecord {
7
7
  referenceId: string;
8
8
  tenantId: string;
9
9
  amountUsdCents: number;
@@ -15,12 +15,12 @@ export interface PayRamChargeRecord {
15
15
  updatedAt: string;
16
16
  }
17
17
 
18
- export interface IPayRamChargeRepository {
18
+ export interface ICryptoChargeRepository {
19
19
  create(referenceId: string, tenantId: string, amountUsdCents: number): Promise<void>;
20
- getByReferenceId(referenceId: string): Promise<PayRamChargeRecord | null>;
20
+ getByReferenceId(referenceId: string): Promise<CryptoChargeRecord | null>;
21
21
  updateStatus(
22
22
  referenceId: string,
23
- status: PayRamPaymentState,
23
+ status: CryptoPaymentState,
24
24
  currency?: string,
25
25
  filledAmount?: string,
26
26
  ): Promise<void>;
@@ -29,27 +29,31 @@ export interface IPayRamChargeRepository {
29
29
  }
30
30
 
31
31
  /**
32
- * Manages PayRam charge records in PostgreSQL.
32
+ * Manages crypto charge records in PostgreSQL.
33
33
  *
34
- * Each charge maps a PayRam reference_id to a tenant and tracks
35
- * the payment lifecycle (OPEN -> VERIFYING -> FILLED/CANCELLED).
34
+ * Each charge maps a BTCPay invoice ID to a tenant and tracks
35
+ * the payment lifecycle (New Processing Settled/Expired/Invalid).
36
+ *
37
+ * amountUsdCents stores the requested amount in USD cents (integer).
38
+ * This is NOT nanodollars — Credit.fromCents() handles the conversion
39
+ * when crediting the ledger in the webhook handler.
36
40
  */
37
- export class DrizzlePayRamChargeRepository implements IPayRamChargeRepository {
41
+ export class DrizzleCryptoChargeRepository implements ICryptoChargeRepository {
38
42
  constructor(private readonly db: PlatformDb) {}
39
43
 
40
- /** Create a new charge record when a payment session is initiated. */
44
+ /** Create a new charge record when an invoice is created. */
41
45
  async create(referenceId: string, tenantId: string, amountUsdCents: number): Promise<void> {
42
- await this.db.insert(payramCharges).values({
46
+ await this.db.insert(cryptoCharges).values({
43
47
  referenceId,
44
48
  tenantId,
45
49
  amountUsdCents,
46
- status: "OPEN",
50
+ status: "New",
47
51
  });
48
52
  }
49
53
 
50
54
  /** Get a charge by reference ID. Returns null if not found. */
51
- async getByReferenceId(referenceId: string): Promise<PayRamChargeRecord | null> {
52
- const row = (await this.db.select().from(payramCharges).where(eq(payramCharges.referenceId, referenceId)))[0];
55
+ async getByReferenceId(referenceId: string): Promise<CryptoChargeRecord | null> {
56
+ const row = (await this.db.select().from(cryptoCharges).where(eq(cryptoCharges.referenceId, referenceId)))[0];
53
57
  if (!row) return null;
54
58
  return {
55
59
  referenceId: row.referenceId,
@@ -67,43 +71,42 @@ export class DrizzlePayRamChargeRepository implements IPayRamChargeRepository {
67
71
  /** Update charge status and payment details from webhook. */
68
72
  async updateStatus(
69
73
  referenceId: string,
70
- status: PayRamPaymentState,
74
+ status: CryptoPaymentState,
71
75
  currency?: string,
72
76
  filledAmount?: string,
73
77
  ): Promise<void> {
74
78
  await this.db
75
- .update(payramCharges)
79
+ .update(cryptoCharges)
76
80
  .set({
77
81
  status,
78
82
  currency,
79
83
  filledAmount,
80
84
  updatedAt: sql`now()`,
81
85
  })
82
- .where(eq(payramCharges.referenceId, referenceId));
86
+ .where(eq(cryptoCharges.referenceId, referenceId));
83
87
  }
84
88
 
85
89
  /** Mark a charge as credited (idempotency flag). */
86
90
  async markCredited(referenceId: string): Promise<void> {
87
91
  await this.db
88
- .update(payramCharges)
92
+ .update(cryptoCharges)
89
93
  .set({
90
94
  creditedAt: sql`now()`,
91
95
  updatedAt: sql`now()`,
92
96
  })
93
- .where(eq(payramCharges.referenceId, referenceId));
97
+ .where(eq(cryptoCharges.referenceId, referenceId));
94
98
  }
95
99
 
96
100
  /** Check if a charge has already been credited (for idempotency). */
97
101
  async isCredited(referenceId: string): Promise<boolean> {
98
102
  const row = (
99
103
  await this.db
100
- .select({ creditedAt: payramCharges.creditedAt })
101
- .from(payramCharges)
102
- .where(eq(payramCharges.referenceId, referenceId))
104
+ .select({ creditedAt: cryptoCharges.creditedAt })
105
+ .from(cryptoCharges)
106
+ .where(eq(cryptoCharges.referenceId, referenceId))
103
107
  )[0];
104
108
  return row?.creditedAt != null;
105
109
  }
106
110
  }
107
111
 
108
- // Backward-compat alias.
109
- export { DrizzlePayRamChargeRepository as PayRamChargeRepository };
112
+ export { DrizzleCryptoChargeRepository as CryptoChargeRepository };