@wopr-network/platform-core 1.14.8 → 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 (116) hide show
  1. package/dist/account/deletion-executor-repository.d.ts +2 -2
  2. package/dist/account/deletion-executor-repository.js +5 -5
  3. package/dist/{monetization/payram → billing/crypto}/cents-credits-boundary.test.js +14 -17
  4. package/dist/billing/{payram → crypto}/charge-store.d.ts +17 -13
  5. package/dist/billing/{payram → crypto}/charge-store.js +21 -18
  6. package/dist/billing/crypto/charge-store.test.js +64 -0
  7. package/dist/billing/crypto/checkout.d.ts +18 -0
  8. package/dist/billing/crypto/checkout.js +35 -0
  9. package/dist/billing/crypto/checkout.test.js +71 -0
  10. package/dist/billing/crypto/client.d.ts +39 -0
  11. package/dist/billing/crypto/client.js +72 -0
  12. package/dist/billing/crypto/client.test.js +100 -0
  13. package/dist/billing/crypto/index.d.ts +9 -0
  14. package/dist/billing/crypto/index.js +5 -0
  15. package/dist/billing/crypto/types.d.ts +61 -0
  16. package/dist/billing/crypto/types.js +24 -0
  17. package/dist/billing/crypto/webhook.d.ts +34 -0
  18. package/dist/billing/crypto/webhook.js +107 -0
  19. package/dist/billing/crypto/webhook.test.js +266 -0
  20. package/dist/billing/index.d.ts +1 -1
  21. package/dist/billing/index.js +2 -2
  22. package/dist/billing/payment-processor.d.ts +3 -3
  23. package/dist/credits/credit-ledger.d.ts +3 -3
  24. package/dist/credits/credit-ledger.js +3 -3
  25. package/dist/db/schema/credits.js +1 -1
  26. package/dist/db/schema/{payram.d.ts → crypto.d.ts} +17 -13
  27. package/dist/db/schema/crypto.js +25 -0
  28. package/dist/db/schema/index.d.ts +1 -1
  29. package/dist/db/schema/index.js +1 -1
  30. package/dist/monetization/crypto/__tests__/webhook.test.js +249 -0
  31. package/dist/monetization/crypto/index.d.ts +4 -0
  32. package/dist/monetization/crypto/index.js +2 -0
  33. package/dist/monetization/crypto/webhook.d.ts +24 -0
  34. package/dist/monetization/crypto/webhook.js +88 -0
  35. package/dist/monetization/index.d.ts +3 -3
  36. package/dist/monetization/index.js +1 -1
  37. package/dist/monetization/repository-types.d.ts +1 -1
  38. package/dist/observability/pagerduty.test.js +1 -0
  39. package/drizzle/migrations/0004_crypto_charges.sql +25 -0
  40. package/drizzle/migrations/meta/_journal.json +7 -0
  41. package/package.json +1 -3
  42. package/src/account/deletion-executor-repository.ts +6 -6
  43. package/src/billing/{payram → crypto}/cents-credits-boundary.test.ts +14 -17
  44. package/src/billing/crypto/charge-store.test.ts +81 -0
  45. package/src/billing/{payram → crypto}/charge-store.ts +28 -25
  46. package/src/billing/crypto/checkout.test.ts +93 -0
  47. package/src/billing/crypto/checkout.ts +48 -0
  48. package/src/billing/crypto/client.test.ts +132 -0
  49. package/src/billing/crypto/client.ts +86 -0
  50. package/src/billing/crypto/index.ts +15 -0
  51. package/src/billing/crypto/types.ts +83 -0
  52. package/src/billing/crypto/webhook.test.ts +340 -0
  53. package/src/billing/crypto/webhook.ts +136 -0
  54. package/src/billing/index.ts +2 -2
  55. package/src/billing/payment-processor.ts +3 -3
  56. package/src/credits/credit-ledger.ts +3 -3
  57. package/src/db/schema/credits.ts +1 -1
  58. package/src/db/schema/crypto.ts +30 -0
  59. package/src/db/schema/index.ts +1 -1
  60. package/src/monetization/crypto/__tests__/webhook.test.ts +327 -0
  61. package/src/monetization/crypto/index.ts +23 -0
  62. package/src/monetization/crypto/webhook.ts +115 -0
  63. package/src/monetization/index.ts +23 -21
  64. package/src/monetization/repository-types.ts +2 -2
  65. package/src/observability/pagerduty.test.ts +1 -0
  66. package/dist/billing/payram/cents-credits-boundary.test.js +0 -75
  67. package/dist/billing/payram/charge-store.test.js +0 -64
  68. package/dist/billing/payram/checkout.d.ts +0 -15
  69. package/dist/billing/payram/checkout.js +0 -24
  70. package/dist/billing/payram/checkout.test.js +0 -74
  71. package/dist/billing/payram/client.d.ts +0 -7
  72. package/dist/billing/payram/client.js +0 -15
  73. package/dist/billing/payram/client.test.js +0 -52
  74. package/dist/billing/payram/index.d.ts +0 -8
  75. package/dist/billing/payram/index.js +0 -4
  76. package/dist/billing/payram/types.d.ts +0 -40
  77. package/dist/billing/payram/webhook.d.ts +0 -19
  78. package/dist/billing/payram/webhook.js +0 -71
  79. package/dist/billing/payram/webhook.test.d.ts +0 -7
  80. package/dist/billing/payram/webhook.test.js +0 -249
  81. package/dist/db/schema/payram.js +0 -21
  82. package/dist/monetization/payram/charge-store.test.d.ts +0 -1
  83. package/dist/monetization/payram/charge-store.test.js +0 -64
  84. package/dist/monetization/payram/checkout.test.d.ts +0 -1
  85. package/dist/monetization/payram/checkout.test.js +0 -73
  86. package/dist/monetization/payram/client.test.d.ts +0 -1
  87. package/dist/monetization/payram/client.test.js +0 -52
  88. package/dist/monetization/payram/index.d.ts +0 -4
  89. package/dist/monetization/payram/index.js +0 -2
  90. package/dist/monetization/payram/webhook.d.ts +0 -17
  91. package/dist/monetization/payram/webhook.js +0 -71
  92. package/dist/monetization/payram/webhook.test.d.ts +0 -7
  93. package/dist/monetization/payram/webhook.test.js +0 -247
  94. package/src/billing/payram/charge-store.test.ts +0 -84
  95. package/src/billing/payram/checkout.test.ts +0 -99
  96. package/src/billing/payram/checkout.ts +0 -40
  97. package/src/billing/payram/client.test.ts +0 -62
  98. package/src/billing/payram/client.ts +0 -21
  99. package/src/billing/payram/index.ts +0 -14
  100. package/src/billing/payram/types.ts +0 -44
  101. package/src/billing/payram/webhook.test.ts +0 -320
  102. package/src/billing/payram/webhook.ts +0 -94
  103. package/src/db/schema/payram.ts +0 -26
  104. package/src/monetization/payram/cents-credits-boundary.test.ts +0 -84
  105. package/src/monetization/payram/charge-store.test.ts +0 -84
  106. package/src/monetization/payram/checkout.test.ts +0 -98
  107. package/src/monetization/payram/client.test.ts +0 -62
  108. package/src/monetization/payram/index.ts +0 -20
  109. package/src/monetization/payram/webhook.test.ts +0 -327
  110. package/src/monetization/payram/webhook.ts +0 -97
  111. /package/dist/billing/{payram → crypto}/cents-credits-boundary.test.d.ts +0 -0
  112. /package/dist/billing/{payram → crypto}/charge-store.test.d.ts +0 -0
  113. /package/dist/billing/{payram → crypto}/checkout.test.d.ts +0 -0
  114. /package/dist/billing/{payram → crypto}/client.test.d.ts +0 -0
  115. /package/dist/billing/{payram/types.js → crypto/webhook.test.d.ts} +0 -0
  116. /package/dist/monetization/{payram/cents-credits-boundary.test.d.ts → crypto/__tests__/webhook.test.d.ts} +0 -0
@@ -2,16 +2,16 @@
2
2
  * NAMING CONVENTION — cents vs credits (WOP-1058)
3
3
  *
4
4
  * - `_cents` suffix = value denominated in USD cents.
5
- * Used for: Stripe amounts, PayRam amounts, ledger balances, ledger transactions.
5
+ * Used for: Stripe amounts, BTCPay amounts, ledger balances, ledger transactions.
6
6
  * The platform credit unit IS the USD cent (1 credit = 1 cent = $0.01).
7
7
  *
8
8
  * - `_credits` suffix = platform credit count (used in DB columns and raw storage).
9
9
  * Semantically identical to cents but signals "this is a platform concept stored
10
10
  * as a Credit.toRaw() nanodollar value in the database."
11
11
  *
12
- * - NEVER rename a Stripe/PayRam-facing `_cents` field to `_credits`.
12
+ * - NEVER rename a Stripe/BTCPay-facing `_cents` field to `_credits`.
13
13
  * Stripe's `amount` parameter and `amount_total` response are always USD cents.
14
- * PayRam's `amountUsdCents` is always USD cents.
14
+ * BTCPay's `amountUsdCents` is always USD cents.
15
15
  *
16
16
  * - When adding new fields: if it touches a payment processor API, use `_cents`.
17
17
  * If it's a DB column storing Credit.toRaw() values, use `_credits`.
@@ -2,16 +2,16 @@
2
2
  * NAMING CONVENTION — cents vs credits (WOP-1058)
3
3
  *
4
4
  * - `_cents` suffix = value denominated in USD cents.
5
- * Used for: Stripe amounts, PayRam amounts, ledger balances, ledger transactions.
5
+ * Used for: Stripe amounts, BTCPay amounts, ledger balances, ledger transactions.
6
6
  * The platform credit unit IS the USD cent (1 credit = 1 cent = $0.01).
7
7
  *
8
8
  * - `_credits` suffix = platform credit count (used in DB columns and raw storage).
9
9
  * Semantically identical to cents but signals "this is a platform concept stored
10
10
  * as a Credit.toRaw() nanodollar value in the database."
11
11
  *
12
- * - NEVER rename a Stripe/PayRam-facing `_cents` field to `_credits`.
12
+ * - NEVER rename a Stripe/BTCPay-facing `_cents` field to `_credits`.
13
13
  * Stripe's `amount` parameter and `amount_total` response are always USD cents.
14
- * PayRam's `amountUsdCents` is always USD cents.
14
+ * BTCPay's `amountUsdCents` is always USD cents.
15
15
  *
16
16
  * - When adding new fields: if it touches a payment processor API, use `_cents`.
17
17
  * If it's a DB column storing Credit.toRaw() values, use `_credits`.
@@ -13,7 +13,7 @@ export const creditTransactions = pgTable("credit_transactions", {
13
13
  type: text("type").notNull(), // signup_grant | purchase | bounty | referral | promo | community_dividend | bot_runtime | adapter_usage | addon | refund | correction
14
14
  description: text("description"),
15
15
  referenceId: text("reference_id").unique(),
16
- fundingSource: text("funding_source"), // "stripe" | "payram" | null (null = legacy/signup)
16
+ fundingSource: text("funding_source"), // "stripe" | "crypto" | null (null = legacy/signup)
17
17
  attributedUserId: text("attributed_user_id"), // nullable — null for system/bot charges
18
18
  createdAt: text("created_at").notNull().default(sql `(now())`),
19
19
  expiresAt: text("expires_at"), // nullable — null means never expires
@@ -1,14 +1,18 @@
1
1
  /**
2
- * PayRam payment sessions — tracks the lifecycle of each crypto payment.
3
- * reference_id is the PayRam-assigned unique identifier.
2
+ * Crypto payment charges — tracks the lifecycle of each BTCPay invoice.
3
+ * reference_id is the BTCPay invoice ID.
4
+ *
5
+ * amountUsdCents stores the requested amount in USD cents (integer).
6
+ * This is NOT nanodollars — Credit.fromCents() handles the conversion
7
+ * when crediting the ledger in the webhook handler.
4
8
  */
5
- export declare const payramCharges: import("drizzle-orm/pg-core").PgTableWithColumns<{
6
- name: "payram_charges";
9
+ export declare const cryptoCharges: import("drizzle-orm/pg-core").PgTableWithColumns<{
10
+ name: "crypto_charges";
7
11
  schema: undefined;
8
12
  columns: {
9
13
  referenceId: import("drizzle-orm/pg-core").PgColumn<{
10
14
  name: "reference_id";
11
- tableName: "payram_charges";
15
+ tableName: "crypto_charges";
12
16
  dataType: "string";
13
17
  columnType: "PgText";
14
18
  data: string;
@@ -25,7 +29,7 @@ export declare const payramCharges: import("drizzle-orm/pg-core").PgTableWithCol
25
29
  }, {}, {}>;
26
30
  tenantId: import("drizzle-orm/pg-core").PgColumn<{
27
31
  name: "tenant_id";
28
- tableName: "payram_charges";
32
+ tableName: "crypto_charges";
29
33
  dataType: "string";
30
34
  columnType: "PgText";
31
35
  data: string;
@@ -42,7 +46,7 @@ export declare const payramCharges: import("drizzle-orm/pg-core").PgTableWithCol
42
46
  }, {}, {}>;
43
47
  amountUsdCents: import("drizzle-orm/pg-core").PgColumn<{
44
48
  name: "amount_usd_cents";
45
- tableName: "payram_charges";
49
+ tableName: "crypto_charges";
46
50
  dataType: "number";
47
51
  columnType: "PgInteger";
48
52
  data: number;
@@ -59,7 +63,7 @@ export declare const payramCharges: import("drizzle-orm/pg-core").PgTableWithCol
59
63
  }, {}, {}>;
60
64
  status: import("drizzle-orm/pg-core").PgColumn<{
61
65
  name: "status";
62
- tableName: "payram_charges";
66
+ tableName: "crypto_charges";
63
67
  dataType: "string";
64
68
  columnType: "PgText";
65
69
  data: string;
@@ -76,7 +80,7 @@ export declare const payramCharges: import("drizzle-orm/pg-core").PgTableWithCol
76
80
  }, {}, {}>;
77
81
  currency: import("drizzle-orm/pg-core").PgColumn<{
78
82
  name: "currency";
79
- tableName: "payram_charges";
83
+ tableName: "crypto_charges";
80
84
  dataType: "string";
81
85
  columnType: "PgText";
82
86
  data: string;
@@ -93,7 +97,7 @@ export declare const payramCharges: import("drizzle-orm/pg-core").PgTableWithCol
93
97
  }, {}, {}>;
94
98
  filledAmount: import("drizzle-orm/pg-core").PgColumn<{
95
99
  name: "filled_amount";
96
- tableName: "payram_charges";
100
+ tableName: "crypto_charges";
97
101
  dataType: "string";
98
102
  columnType: "PgText";
99
103
  data: string;
@@ -110,7 +114,7 @@ export declare const payramCharges: import("drizzle-orm/pg-core").PgTableWithCol
110
114
  }, {}, {}>;
111
115
  createdAt: import("drizzle-orm/pg-core").PgColumn<{
112
116
  name: "created_at";
113
- tableName: "payram_charges";
117
+ tableName: "crypto_charges";
114
118
  dataType: "string";
115
119
  columnType: "PgText";
116
120
  data: string;
@@ -127,7 +131,7 @@ export declare const payramCharges: import("drizzle-orm/pg-core").PgTableWithCol
127
131
  }, {}, {}>;
128
132
  updatedAt: import("drizzle-orm/pg-core").PgColumn<{
129
133
  name: "updated_at";
130
- tableName: "payram_charges";
134
+ tableName: "crypto_charges";
131
135
  dataType: "string";
132
136
  columnType: "PgText";
133
137
  data: string;
@@ -144,7 +148,7 @@ export declare const payramCharges: import("drizzle-orm/pg-core").PgTableWithCol
144
148
  }, {}, {}>;
145
149
  creditedAt: import("drizzle-orm/pg-core").PgColumn<{
146
150
  name: "credited_at";
147
- tableName: "payram_charges";
151
+ tableName: "crypto_charges";
148
152
  dataType: "string";
149
153
  columnType: "PgText";
150
154
  data: string;
@@ -0,0 +1,25 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { index, integer, pgTable, text } from "drizzle-orm/pg-core";
3
+ /**
4
+ * Crypto payment charges — tracks the lifecycle of each BTCPay invoice.
5
+ * reference_id is the BTCPay invoice ID.
6
+ *
7
+ * amountUsdCents stores the requested amount in USD cents (integer).
8
+ * This is NOT nanodollars — Credit.fromCents() handles the conversion
9
+ * when crediting the ledger in the webhook handler.
10
+ */
11
+ export const cryptoCharges = pgTable("crypto_charges", {
12
+ referenceId: text("reference_id").primaryKey(),
13
+ tenantId: text("tenant_id").notNull(),
14
+ amountUsdCents: integer("amount_usd_cents").notNull(),
15
+ status: text("status").notNull().default("New"),
16
+ currency: text("currency"),
17
+ filledAmount: text("filled_amount"),
18
+ createdAt: text("created_at").notNull().default(sql `(now())`),
19
+ updatedAt: text("updated_at").notNull().default(sql `(now())`),
20
+ creditedAt: text("credited_at"),
21
+ }, (table) => [
22
+ index("idx_crypto_charges_tenant").on(table.tenantId),
23
+ index("idx_crypto_charges_status").on(table.status),
24
+ index("idx_crypto_charges_created").on(table.createdAt),
25
+ ]);
@@ -16,6 +16,7 @@ export * from "./coupon-codes.js";
16
16
  export * from "./credit-auto-topup.js";
17
17
  export * from "./credit-auto-topup-settings.js";
18
18
  export * from "./credits.js";
19
+ export * from "./crypto.js";
19
20
  export * from "./dividend-distributions.js";
20
21
  export * from "./email-notifications.js";
21
22
  export * from "./fleet-event-history.js";
@@ -39,7 +40,6 @@ export * from "./onboarding-sessions.js";
39
40
  export * from "./org-memberships.js";
40
41
  export * from "./organization-members.js";
41
42
  export * from "./page-contexts.js";
42
- export * from "./payram.js";
43
43
  export * from "./platform-api-keys.js";
44
44
  export * from "./plugin-configs.js";
45
45
  export * from "./plugin-marketplace-content.js";
@@ -16,6 +16,7 @@ export * from "./coupon-codes.js";
16
16
  export * from "./credit-auto-topup.js";
17
17
  export * from "./credit-auto-topup-settings.js";
18
18
  export * from "./credits.js";
19
+ export * from "./crypto.js";
19
20
  export * from "./dividend-distributions.js";
20
21
  export * from "./email-notifications.js";
21
22
  export * from "./fleet-event-history.js";
@@ -39,7 +40,6 @@ export * from "./onboarding-sessions.js";
39
40
  export * from "./org-memberships.js";
40
41
  export * from "./organization-members.js";
41
42
  export * from "./page-contexts.js";
42
- export * from "./payram.js";
43
43
  export * from "./platform-api-keys.js";
44
44
  export * from "./plugin-configs.js";
45
45
  export * from "./plugin-marketplace-content.js";
@@ -0,0 +1,249 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { CryptoChargeRepository } from "../../../billing/crypto/charge-store.js";
3
+ import { DrizzleWebhookSeenRepository } from "../../../billing/drizzle-webhook-seen-repository.js";
4
+ import { noOpReplayGuard } from "../../../billing/webhook-seen-repository.js";
5
+ import { DrizzleLedger } from "../../../credits/ledger.js";
6
+ import { createTestDb, truncateAllTables } from "../../../test/db.js";
7
+ import { handleCryptoWebhook } from "../webhook.js";
8
+ function makePayload(overrides = {}) {
9
+ return {
10
+ deliveryId: "del-001",
11
+ webhookId: "whk-001",
12
+ originalDeliveryId: "del-001",
13
+ isRedelivery: false,
14
+ type: "InvoiceSettled",
15
+ timestamp: Date.now(),
16
+ storeId: "store-test",
17
+ invoiceId: "inv-test-001",
18
+ metadata: { orderId: "order-001" },
19
+ ...overrides,
20
+ };
21
+ }
22
+ let pool;
23
+ let db;
24
+ beforeAll(async () => {
25
+ ({ db, pool } = await createTestDb());
26
+ });
27
+ afterAll(async () => {
28
+ await pool.close();
29
+ });
30
+ describe("handleCryptoWebhook (monetization layer)", () => {
31
+ let chargeStore;
32
+ let creditLedger;
33
+ let deps;
34
+ beforeEach(async () => {
35
+ await truncateAllTables(pool);
36
+ chargeStore = new CryptoChargeRepository(db);
37
+ creditLedger = new DrizzleLedger(db);
38
+ await creditLedger.seedSystemAccounts();
39
+ deps = { chargeStore, creditLedger, replayGuard: noOpReplayGuard };
40
+ // Default test charge: $25 = 2500 cents
41
+ await chargeStore.create("inv-test-001", "tenant-a", 2500);
42
+ });
43
+ // ---------------------------------------------------------------------------
44
+ // InvoiceSettled — credits ledger
45
+ // ---------------------------------------------------------------------------
46
+ describe("InvoiceSettled", () => {
47
+ it("credits the ledger with the USD amount in cents", async () => {
48
+ const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
49
+ expect(result.handled).toBe(true);
50
+ expect(result.status).toBe("Settled");
51
+ expect(result.tenant).toBe("tenant-a");
52
+ expect(result.creditedCents).toBe(2500);
53
+ const balance = await creditLedger.balance("tenant-a");
54
+ expect(balance.toCents()).toBe(2500);
55
+ });
56
+ it("marks the charge as credited after settlement", async () => {
57
+ await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
58
+ expect(await chargeStore.isCredited("inv-test-001")).toBe(true);
59
+ });
60
+ it("uses crypto: prefix on reference ID in ledger entry", async () => {
61
+ await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
62
+ const history = await creditLedger.history("tenant-a");
63
+ expect(history).toHaveLength(1);
64
+ expect(history[0].referenceId).toBe("crypto:inv-test-001");
65
+ expect(history[0].entryType).toBe("purchase");
66
+ });
67
+ it("records fundingSource as crypto", async () => {
68
+ await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
69
+ const history = await creditLedger.history("tenant-a");
70
+ expect(history[0].metadata?.fundingSource).toBe("crypto");
71
+ });
72
+ it("is idempotent — second InvoiceSettled does not double-credit", async () => {
73
+ await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
74
+ const result2 = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
75
+ expect(result2.handled).toBe(true);
76
+ expect(result2.creditedCents).toBe(0);
77
+ // Balance is still $25, not $50
78
+ const balance = await creditLedger.balance("tenant-a");
79
+ expect(balance.toCents()).toBe(2500);
80
+ });
81
+ });
82
+ // ---------------------------------------------------------------------------
83
+ // Non-settlement event types — no ledger credit
84
+ // ---------------------------------------------------------------------------
85
+ describe("InvoiceProcessing", () => {
86
+ it("does NOT credit the ledger", async () => {
87
+ const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceProcessing" }));
88
+ expect(result.handled).toBe(true);
89
+ expect(result.tenant).toBe("tenant-a");
90
+ expect(result.creditedCents).toBeUndefined();
91
+ expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
92
+ });
93
+ });
94
+ describe("InvoiceCreated", () => {
95
+ it("does NOT credit the ledger", async () => {
96
+ const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceCreated" }));
97
+ expect(result.handled).toBe(true);
98
+ expect(result.creditedCents).toBeUndefined();
99
+ expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
100
+ });
101
+ });
102
+ describe("InvoiceExpired", () => {
103
+ it("does NOT credit the ledger", async () => {
104
+ const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceExpired" }));
105
+ expect(result.handled).toBe(true);
106
+ expect(result.creditedCents).toBeUndefined();
107
+ expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
108
+ });
109
+ });
110
+ describe("InvoiceInvalid", () => {
111
+ it("does NOT credit the ledger", async () => {
112
+ const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceInvalid" }));
113
+ expect(result.handled).toBe(true);
114
+ expect(result.creditedCents).toBeUndefined();
115
+ expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
116
+ });
117
+ });
118
+ // ---------------------------------------------------------------------------
119
+ // Unknown invoiceId — returns handled:false
120
+ // ---------------------------------------------------------------------------
121
+ describe("missing charge", () => {
122
+ it("returns handled:false when invoiceId is not in the charge store", async () => {
123
+ const result = await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-unknown-999" }));
124
+ expect(result.handled).toBe(false);
125
+ expect(result.tenant).toBeUndefined();
126
+ });
127
+ });
128
+ // ---------------------------------------------------------------------------
129
+ // Status mapping for all known event types
130
+ // ---------------------------------------------------------------------------
131
+ describe("status mapping", () => {
132
+ it.each([
133
+ ["InvoiceCreated", "New"],
134
+ ["InvoiceProcessing", "Processing"],
135
+ ["InvoiceReceivedPayment", "Processing"],
136
+ ["InvoiceSettled", "Settled"],
137
+ ["InvoicePaymentSettled", "Settled"],
138
+ ["InvoiceExpired", "Expired"],
139
+ ["InvoiceInvalid", "Invalid"],
140
+ ])("maps %s event to %s status", async (eventType, expectedStatus) => {
141
+ const result = await handleCryptoWebhook(deps, makePayload({ type: eventType }));
142
+ expect(result.status).toBe(expectedStatus);
143
+ });
144
+ it("throws on unknown event types", async () => {
145
+ await expect(handleCryptoWebhook(deps, makePayload({ type: "InvoiceSomeUnknownEvent" }))).rejects.toThrow("Unknown BTCPay event type: InvoiceSomeUnknownEvent");
146
+ });
147
+ });
148
+ // ---------------------------------------------------------------------------
149
+ // Charge store status updates
150
+ // ---------------------------------------------------------------------------
151
+ describe("charge store updates", () => {
152
+ it("updates charge status on every webhook call", async () => {
153
+ await handleCryptoWebhook(deps, makePayload({ type: "InvoiceProcessing" }));
154
+ const charge = await chargeStore.getByReferenceId("inv-test-001");
155
+ expect(charge?.status).toBe("Processing");
156
+ });
157
+ });
158
+ // ---------------------------------------------------------------------------
159
+ // Replay guard / idempotency
160
+ // ---------------------------------------------------------------------------
161
+ describe("replay guard", () => {
162
+ it("blocks duplicate invoiceId + event type combinations", async () => {
163
+ const replayGuard = new DrizzleWebhookSeenRepository(db);
164
+ const depsWithGuard = { ...deps, replayGuard };
165
+ const first = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
166
+ expect(first.handled).toBe(true);
167
+ expect(first.creditedCents).toBe(2500);
168
+ expect(first.duplicate).toBeUndefined();
169
+ const second = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
170
+ expect(second.handled).toBe(true);
171
+ expect(second.duplicate).toBe(true);
172
+ expect(second.creditedCents).toBeUndefined();
173
+ // Balance is still $25, not $50
174
+ expect((await creditLedger.balance("tenant-a")).toCents()).toBe(2500);
175
+ });
176
+ it("same invoice with a different event type is not blocked", async () => {
177
+ const replayGuard = new DrizzleWebhookSeenRepository(db);
178
+ const depsWithGuard = { ...deps, replayGuard };
179
+ await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceProcessing" }));
180
+ const result = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
181
+ expect(result.duplicate).toBeUndefined();
182
+ expect(result.creditedCents).toBe(2500);
183
+ });
184
+ });
185
+ // ---------------------------------------------------------------------------
186
+ // BotBilling reactivation — WOPR-specific behaviour
187
+ // ---------------------------------------------------------------------------
188
+ describe("BotBilling reactivation", () => {
189
+ it("calls botBilling.checkReactivation on InvoiceSettled and returns reactivatedBots", async () => {
190
+ const mockBotBilling = {
191
+ checkReactivation: vi.fn().mockResolvedValue(["bot-1", "bot-2"]),
192
+ };
193
+ const depsWithBots = { ...deps, botBilling: mockBotBilling };
194
+ const result = await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceSettled" }));
195
+ expect(mockBotBilling.checkReactivation).toHaveBeenCalledWith("tenant-a", creditLedger);
196
+ expect(result.reactivatedBots).toEqual(["bot-1", "bot-2"]);
197
+ });
198
+ it("omits reactivatedBots when no bots are reactivated", async () => {
199
+ const mockBotBilling = {
200
+ checkReactivation: vi.fn().mockResolvedValue([]),
201
+ };
202
+ const depsWithBots = { ...deps, botBilling: mockBotBilling };
203
+ const result = await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceSettled" }));
204
+ expect(result.reactivatedBots).toBeUndefined();
205
+ });
206
+ it("does NOT call botBilling on non-settled events", async () => {
207
+ const mockBotBilling = {
208
+ checkReactivation: vi.fn().mockResolvedValue(["bot-1"]),
209
+ };
210
+ const depsWithBots = { ...deps, botBilling: mockBotBilling };
211
+ await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceProcessing" }));
212
+ expect(mockBotBilling.checkReactivation).not.toHaveBeenCalled();
213
+ });
214
+ it("does NOT call botBilling when charge is already credited (idempotency path)", async () => {
215
+ const mockBotBilling = {
216
+ checkReactivation: vi.fn().mockResolvedValue(["bot-1"]),
217
+ };
218
+ const depsWithBots = { ...deps, botBilling: mockBotBilling };
219
+ // First settlement — should call reactivation
220
+ await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceSettled" }));
221
+ expect(mockBotBilling.checkReactivation).toHaveBeenCalledTimes(1);
222
+ // Second settlement — charge already credited, should NOT call reactivation again
223
+ await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceSettled" }));
224
+ expect(mockBotBilling.checkReactivation).toHaveBeenCalledTimes(1);
225
+ });
226
+ it("operates correctly when botBilling is not provided", async () => {
227
+ // No botBilling dependency — should complete without error
228
+ const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
229
+ expect(result.handled).toBe(true);
230
+ expect(result.creditedCents).toBe(2500);
231
+ expect(result.reactivatedBots).toBeUndefined();
232
+ });
233
+ });
234
+ // ---------------------------------------------------------------------------
235
+ // Multiple tenants — independent processing
236
+ // ---------------------------------------------------------------------------
237
+ describe("multiple tenants", () => {
238
+ it("processes invoices for different tenants independently", async () => {
239
+ await chargeStore.create("inv-b-001", "tenant-b", 5000);
240
+ await chargeStore.create("inv-c-001", "tenant-c", 1500);
241
+ await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-b-001", type: "InvoiceSettled" }));
242
+ await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-c-001", type: "InvoiceSettled" }));
243
+ expect((await creditLedger.balance("tenant-b")).toCents()).toBe(5000);
244
+ expect((await creditLedger.balance("tenant-c")).toCents()).toBe(1500);
245
+ // Original tenant-a was not settled in this test
246
+ expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
247
+ });
248
+ });
249
+ });
@@ -0,0 +1,4 @@
1
+ export type { CryptoBillingConfig, CryptoChargeRecord, CryptoCheckoutOpts, CryptoConfig, CryptoPaymentState, CryptoWebhookPayload, CryptoWebhookResult, ICryptoChargeRepository, } from "@wopr-network/platform-core/billing";
2
+ export { BTCPayClient, CryptoChargeRepository, createCryptoCheckout, DrizzleCryptoChargeRepository, loadCryptoConfig, MIN_PAYMENT_USD, mapBtcPayEventToStatus, verifyCryptoWebhookSignature, } from "@wopr-network/platform-core/billing";
3
+ export type { CryptoWebhookDeps } from "./webhook.js";
4
+ export { handleCryptoWebhook } from "./webhook.js";
@@ -0,0 +1,2 @@
1
+ export { BTCPayClient, CryptoChargeRepository, createCryptoCheckout, DrizzleCryptoChargeRepository, loadCryptoConfig, MIN_PAYMENT_USD, mapBtcPayEventToStatus, verifyCryptoWebhookSignature, } from "@wopr-network/platform-core/billing";
2
+ export { handleCryptoWebhook } from "./webhook.js";
@@ -0,0 +1,24 @@
1
+ import type { CryptoWebhookPayload, CryptoWebhookResult, ICryptoChargeRepository, IWebhookSeenRepository } from "@wopr-network/platform-core/billing";
2
+ import type { ILedger } from "@wopr-network/platform-core/credits";
3
+ import type { BotBilling } from "../credits/bot-billing.js";
4
+ export interface CryptoWebhookDeps {
5
+ chargeStore: ICryptoChargeRepository;
6
+ creditLedger: ILedger;
7
+ botBilling?: BotBilling;
8
+ replayGuard: IWebhookSeenRepository;
9
+ }
10
+ /**
11
+ * Process a BTCPay Server webhook event (WOPR-specific version).
12
+ *
13
+ * Only credits the ledger on InvoiceSettled.
14
+ * Uses botBilling.checkReactivation for WOPR bot suspension recovery.
15
+ *
16
+ * Idempotency strategy (matches Stripe webhook pattern):
17
+ * Primary: `creditLedger.hasReferenceId("crypto:<invoiceId>")` — atomic,
18
+ * checked inside the ledger's serialized transaction.
19
+ * Secondary: `chargeStore.markCredited()` — advisory flag for queries.
20
+ *
21
+ * CRITICAL: charge.amountUsdCents is in USD cents (integer).
22
+ * Credit.fromCents() converts cents → nanodollars for the ledger.
23
+ */
24
+ export declare function handleCryptoWebhook(deps: CryptoWebhookDeps, payload: CryptoWebhookPayload): Promise<CryptoWebhookResult>;
@@ -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 () => {