@wopr-network/platform-core 1.14.8 → 1.16.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 (154) 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/crypto/charge-store.d.ts +68 -0
  5. package/dist/billing/crypto/charge-store.js +109 -0
  6. package/dist/billing/crypto/charge-store.test.js +120 -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/evm/__tests__/address-gen.test.js +54 -0
  14. package/dist/billing/crypto/evm/__tests__/checkout.test.js +54 -0
  15. package/dist/billing/crypto/evm/__tests__/config.test.js +52 -0
  16. package/dist/billing/crypto/evm/__tests__/settler.test.js +196 -0
  17. package/dist/billing/crypto/evm/__tests__/watcher.test.js +109 -0
  18. package/dist/billing/crypto/evm/address-gen.d.ts +8 -0
  19. package/dist/billing/crypto/evm/address-gen.js +29 -0
  20. package/dist/billing/crypto/evm/checkout.d.ts +26 -0
  21. package/dist/billing/crypto/evm/checkout.js +57 -0
  22. package/dist/billing/crypto/evm/config.d.ts +13 -0
  23. package/dist/billing/crypto/evm/config.js +46 -0
  24. package/dist/billing/crypto/evm/index.d.ts +9 -0
  25. package/dist/billing/crypto/evm/index.js +5 -0
  26. package/dist/billing/crypto/evm/settler.d.ts +23 -0
  27. package/dist/billing/crypto/evm/settler.js +60 -0
  28. package/dist/billing/crypto/evm/types.d.ts +40 -0
  29. package/dist/billing/crypto/evm/types.js +1 -0
  30. package/dist/billing/crypto/evm/watcher.d.ts +31 -0
  31. package/dist/billing/crypto/evm/watcher.js +91 -0
  32. package/dist/billing/crypto/index.d.ts +10 -0
  33. package/dist/billing/crypto/index.js +6 -0
  34. package/dist/billing/crypto/types.d.ts +61 -0
  35. package/dist/billing/crypto/types.js +24 -0
  36. package/dist/billing/crypto/webhook.d.ts +34 -0
  37. package/dist/billing/crypto/webhook.js +107 -0
  38. package/dist/billing/crypto/webhook.test.d.ts +1 -0
  39. package/dist/billing/crypto/webhook.test.js +266 -0
  40. package/dist/billing/index.d.ts +1 -1
  41. package/dist/billing/index.js +2 -2
  42. package/dist/billing/payment-processor.d.ts +3 -3
  43. package/dist/credits/credit-ledger.d.ts +3 -3
  44. package/dist/credits/credit-ledger.js +3 -3
  45. package/dist/db/schema/credits.js +1 -1
  46. package/dist/db/schema/{payram.d.ts → crypto.d.ts} +85 -13
  47. package/dist/db/schema/crypto.js +32 -0
  48. package/dist/db/schema/index.d.ts +1 -1
  49. package/dist/db/schema/index.js +1 -1
  50. package/dist/monetization/crypto/__tests__/webhook.test.d.ts +1 -0
  51. package/dist/monetization/crypto/__tests__/webhook.test.js +249 -0
  52. package/dist/monetization/crypto/index.d.ts +4 -0
  53. package/dist/monetization/crypto/index.js +2 -0
  54. package/dist/monetization/crypto/webhook.d.ts +24 -0
  55. package/dist/monetization/crypto/webhook.js +88 -0
  56. package/dist/monetization/index.d.ts +3 -3
  57. package/dist/monetization/index.js +1 -1
  58. package/dist/monetization/repository-types.d.ts +1 -1
  59. package/dist/observability/pagerduty.test.js +1 -0
  60. package/docs/superpowers/plans/2026-03-14-stablecoin-phase1.md +1413 -0
  61. package/drizzle/migrations/0004_crypto_charges.sql +25 -0
  62. package/drizzle/migrations/0005_stablecoin_columns.sql +7 -0
  63. package/drizzle/migrations/meta/_journal.json +14 -0
  64. package/package.json +4 -3
  65. package/src/account/deletion-executor-repository.ts +6 -6
  66. package/src/billing/{payram → crypto}/cents-credits-boundary.test.ts +14 -17
  67. package/src/billing/crypto/charge-store.test.ts +142 -0
  68. package/src/billing/crypto/charge-store.ts +166 -0
  69. package/src/billing/crypto/checkout.test.ts +93 -0
  70. package/src/billing/crypto/checkout.ts +48 -0
  71. package/src/billing/crypto/client.test.ts +132 -0
  72. package/src/billing/crypto/client.ts +86 -0
  73. package/src/billing/crypto/evm/__tests__/address-gen.test.ts +63 -0
  74. package/src/billing/crypto/evm/__tests__/checkout.test.ts +83 -0
  75. package/src/billing/crypto/evm/__tests__/config.test.ts +63 -0
  76. package/src/billing/crypto/evm/__tests__/settler.test.ts +218 -0
  77. package/src/billing/crypto/evm/__tests__/watcher.test.ts +128 -0
  78. package/src/billing/crypto/evm/address-gen.ts +29 -0
  79. package/src/billing/crypto/evm/checkout.ts +82 -0
  80. package/src/billing/crypto/evm/config.ts +50 -0
  81. package/src/billing/crypto/evm/index.ts +16 -0
  82. package/src/billing/crypto/evm/settler.ts +79 -0
  83. package/src/billing/crypto/evm/types.ts +45 -0
  84. package/src/billing/crypto/evm/watcher.ts +126 -0
  85. package/src/billing/crypto/index.ts +16 -0
  86. package/src/billing/crypto/types.ts +83 -0
  87. package/src/billing/crypto/webhook.test.ts +340 -0
  88. package/src/billing/crypto/webhook.ts +136 -0
  89. package/src/billing/index.ts +2 -2
  90. package/src/billing/payment-processor.ts +3 -3
  91. package/src/credits/credit-ledger.ts +3 -3
  92. package/src/db/schema/credits.ts +1 -1
  93. package/src/db/schema/crypto.ts +37 -0
  94. package/src/db/schema/index.ts +1 -1
  95. package/src/monetization/crypto/__tests__/webhook.test.ts +327 -0
  96. package/src/monetization/crypto/index.ts +23 -0
  97. package/src/monetization/crypto/webhook.ts +115 -0
  98. package/src/monetization/index.ts +23 -21
  99. package/src/monetization/repository-types.ts +2 -2
  100. package/src/observability/pagerduty.test.ts +1 -0
  101. package/dist/billing/payram/cents-credits-boundary.test.js +0 -75
  102. package/dist/billing/payram/charge-store.d.ts +0 -41
  103. package/dist/billing/payram/charge-store.js +0 -72
  104. package/dist/billing/payram/charge-store.test.js +0 -64
  105. package/dist/billing/payram/checkout.d.ts +0 -15
  106. package/dist/billing/payram/checkout.js +0 -24
  107. package/dist/billing/payram/checkout.test.js +0 -74
  108. package/dist/billing/payram/client.d.ts +0 -7
  109. package/dist/billing/payram/client.js +0 -15
  110. package/dist/billing/payram/client.test.js +0 -52
  111. package/dist/billing/payram/index.d.ts +0 -8
  112. package/dist/billing/payram/index.js +0 -4
  113. package/dist/billing/payram/types.d.ts +0 -40
  114. package/dist/billing/payram/webhook.d.ts +0 -19
  115. package/dist/billing/payram/webhook.js +0 -71
  116. package/dist/billing/payram/webhook.test.d.ts +0 -7
  117. package/dist/billing/payram/webhook.test.js +0 -249
  118. package/dist/db/schema/payram.js +0 -21
  119. package/dist/monetization/payram/charge-store.test.js +0 -64
  120. package/dist/monetization/payram/checkout.test.js +0 -73
  121. package/dist/monetization/payram/client.test.js +0 -52
  122. package/dist/monetization/payram/index.d.ts +0 -4
  123. package/dist/monetization/payram/index.js +0 -2
  124. package/dist/monetization/payram/webhook.d.ts +0 -17
  125. package/dist/monetization/payram/webhook.js +0 -71
  126. package/dist/monetization/payram/webhook.test.d.ts +0 -7
  127. package/dist/monetization/payram/webhook.test.js +0 -247
  128. package/src/billing/payram/charge-store.test.ts +0 -84
  129. package/src/billing/payram/charge-store.ts +0 -109
  130. package/src/billing/payram/checkout.test.ts +0 -99
  131. package/src/billing/payram/checkout.ts +0 -40
  132. package/src/billing/payram/client.test.ts +0 -62
  133. package/src/billing/payram/client.ts +0 -21
  134. package/src/billing/payram/index.ts +0 -14
  135. package/src/billing/payram/types.ts +0 -44
  136. package/src/billing/payram/webhook.test.ts +0 -320
  137. package/src/billing/payram/webhook.ts +0 -94
  138. package/src/db/schema/payram.ts +0 -26
  139. package/src/monetization/payram/cents-credits-boundary.test.ts +0 -84
  140. package/src/monetization/payram/charge-store.test.ts +0 -84
  141. package/src/monetization/payram/checkout.test.ts +0 -98
  142. package/src/monetization/payram/client.test.ts +0 -62
  143. package/src/monetization/payram/index.ts +0 -20
  144. package/src/monetization/payram/webhook.test.ts +0 -327
  145. package/src/monetization/payram/webhook.ts +0 -97
  146. /package/dist/billing/{payram → crypto}/cents-credits-boundary.test.d.ts +0 -0
  147. /package/dist/billing/{payram → crypto}/charge-store.test.d.ts +0 -0
  148. /package/dist/billing/{payram → crypto}/checkout.test.d.ts +0 -0
  149. /package/dist/billing/{payram → crypto}/client.test.d.ts +0 -0
  150. /package/dist/billing/{payram/types.js → crypto/evm/__tests__/address-gen.test.d.ts} +0 -0
  151. /package/dist/{monetization/payram → billing/crypto/evm/__tests__}/checkout.test.d.ts +0 -0
  152. /package/dist/{monetization/payram/cents-credits-boundary.test.d.ts → billing/crypto/evm/__tests__/config.test.d.ts} +0 -0
  153. /package/dist/{monetization/payram/charge-store.test.d.ts → billing/crypto/evm/__tests__/settler.test.d.ts} +0 -0
  154. /package/dist/{monetization/payram/client.test.d.ts → billing/crypto/evm/__tests__/watcher.test.d.ts} +0 -0
@@ -27,7 +27,7 @@ export interface IDeletionExecutorRepository {
27
27
  }[]>;
28
28
  deleteSnapshots(tenantId: string): Promise<number>;
29
29
  deleteBackupStatus(tenantId: string): Promise<number>;
30
- deletePayramCharges(tenantId: string): Promise<number>;
30
+ deleteCryptoCharges(tenantId: string): Promise<number>;
31
31
  deleteTenantStatus(tenantId: string): Promise<number>;
32
32
  deleteUserRolesByUser(tenantId: string): Promise<number>;
33
33
  deleteUserRolesByTenant(tenantId: string): Promise<number>;
@@ -71,7 +71,7 @@ export declare class DrizzleDeletionExecutorRepository implements IDeletionExecu
71
71
  }[]>;
72
72
  deleteSnapshots(tenantId: string): Promise<number>;
73
73
  deleteBackupStatus(tenantId: string): Promise<number>;
74
- deletePayramCharges(tenantId: string): Promise<number>;
74
+ deleteCryptoCharges(tenantId: string): Promise<number>;
75
75
  deleteTenantStatus(tenantId: string): Promise<number>;
76
76
  deleteUserRolesByUser(tenantId: string): Promise<number>;
77
77
  deleteUserRolesByTenant(tenantId: string): Promise<number>;
@@ -1,5 +1,5 @@
1
1
  import { and, eq, like, lte, or, sql } from "drizzle-orm";
2
- import { accountDeletionRequests, adminAuditLog, adminNotes, auditLog, backupStatus, billingPeriodSummaries, botInstances, creditBalances, creditTransactions, emailNotifications, meterEvents, notificationPreferences, notificationQueue, payramCharges, snapshots, stripeUsageReports, tenantCustomers, tenantStatus, usageSummaries, userRoles, } from "../db/schema/index.js";
2
+ import { accountDeletionRequests, adminAuditLog, adminNotes, auditLog, backupStatus, billingPeriodSummaries, botInstances, creditBalances, creditTransactions, cryptoCharges, emailNotifications, meterEvents, notificationPreferences, notificationQueue, snapshots, stripeUsageReports, tenantCustomers, tenantStatus, usageSummaries, userRoles, } from "../db/schema/index.js";
3
3
  import { toRow } from "./deletion-repository.js";
4
4
  // ---------------------------------------------------------------------------
5
5
  // Implementation
@@ -172,11 +172,11 @@ export class DrizzleDeletionExecutorRepository {
172
172
  .returning({ containerId: backupStatus.containerId });
173
173
  return result.length;
174
174
  }
175
- async deletePayramCharges(tenantId) {
175
+ async deleteCryptoCharges(tenantId) {
176
176
  const result = await this.db
177
- .delete(payramCharges)
178
- .where(eq(payramCharges.tenantId, tenantId))
179
- .returning({ referenceId: payramCharges.referenceId });
177
+ .delete(cryptoCharges)
178
+ .where(eq(cryptoCharges.tenantId, tenantId))
179
+ .returning({ referenceId: cryptoCharges.referenceId });
180
180
  return result.length;
181
181
  }
182
182
  async deleteTenantStatus(tenantId) {
@@ -1,41 +1,39 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { Credit } from "../../credits/credit.js";
2
3
  /**
3
- * Regression tests for PayRam cents/credits boundary (WOP-1058).
4
+ * Regression tests for crypto cents/credits boundary.
4
5
  *
5
6
  * Verifies that the USD-to-cents conversion in checkout and
6
7
  * the cents-to-credits flow in the webhook maintain correct units.
7
8
  *
8
9
  * If any of these tests fail after a rename/refactor, a _cents field was
9
10
  * incorrectly changed to store Credit raw units (nanodollars) instead of
10
- * USD cents. See src/monetization/credits/credit-ledger.ts for naming convention.
11
+ * USD cents. See src/credits/credit-ledger.ts for naming convention.
11
12
  */
12
- describe("WOP-1058: PayRam cents/credits boundary", () => {
13
- it("USD to cents conversion is correct (mirrors checkout.ts pattern)", () => {
14
- // This mirrors the conversion in payram/checkout.ts: Math.round(opts.amountUsd * 100)
13
+ describe("Crypto cents/credits boundary", () => {
14
+ it("USD to cents conversion is correct (mirrors checkout.ts Credit.fromDollars pattern)", () => {
15
+ // This mirrors the conversion in crypto/checkout.ts: Credit.fromDollars(amountUsd).toCentsRounded()
15
16
  const amountUsd = 25;
16
- const amountUsdCents = Math.round(amountUsd * 100);
17
+ const amountUsdCents = Credit.fromDollars(amountUsd).toCentsRounded();
17
18
  expect(amountUsdCents).toBe(2500);
18
19
  // Must NOT be nanodollar scale
19
20
  expect(amountUsdCents).toBeLessThan(1_000_000);
20
21
  });
21
22
  it("minimum payment amount converts to valid cents", () => {
22
23
  const MIN_PAYMENT_USD = 10;
23
- const cents = Math.round(MIN_PAYMENT_USD * 100);
24
+ const cents = Credit.fromDollars(MIN_PAYMENT_USD).toCentsRounded();
24
25
  expect(cents).toBe(1000);
25
26
  expect(Number.isInteger(cents)).toBe(true);
26
27
  // Sanity: $10 is 1000 cents, NOT 10_000_000_000 nanodollars
27
28
  expect(cents).toBeLessThan(1_000_000);
28
29
  });
29
30
  it("fractional USD amounts round correctly to cents", () => {
30
- // Edge case: floating point conversion
31
31
  const amountUsd = 10.99;
32
- const cents = Math.round(amountUsd * 100);
32
+ const cents = Credit.fromDollars(amountUsd).toCentsRounded();
33
33
  expect(cents).toBe(1099);
34
34
  expect(cents).toBeLessThan(1_000_000);
35
35
  });
36
36
  it("amountUsdCents stored in charge record equals USD * 100 (not nanodollars)", () => {
37
- // The core invariant: payram/checkout.ts stores Math.round(amountUsd * 100)
38
- // as amountUsdCents. This test proves the conversion stays at cent scale.
39
37
  const testCases = [
40
38
  { usd: 10, expectedCents: 1000 },
41
39
  { usd: 25, expectedCents: 2500 },
@@ -43,17 +41,17 @@ describe("WOP-1058: PayRam cents/credits boundary", () => {
43
41
  { usd: 100, expectedCents: 10000 },
44
42
  ];
45
43
  for (const { usd, expectedCents } of testCases) {
46
- const amountUsdCents = Math.round(usd * 100);
44
+ const amountUsdCents = Credit.fromDollars(usd).toCentsRounded();
47
45
  expect(amountUsdCents).toBe(expectedCents);
48
46
  // CREDIT SCALE = 1_000_000_000. If this value approaches that, unit confusion occurred.
49
47
  expect(amountUsdCents).toBeLessThan(1_000_000);
50
48
  }
51
49
  });
52
- it("creditedCents in webhook equals amountUsdCents from charge store (1:1 for PayRam)", () => {
53
- // payram/webhook.ts: const creditCents = charge.amountUsdCents;
54
- // The credited amount always equals the stored USD cents — no bonus tiers for PayRam.
50
+ it("creditedCents in webhook equals amountUsdCents from charge store (1:1 for crypto)", () => {
51
+ // crypto/webhook.ts: const creditCents = charge.amountUsdCents;
52
+ // The credited amount always equals the stored USD cents — no bonus tiers for crypto.
55
53
  const chargeAmountUsdCents = 2500; // $25.00
56
- const creditCents = chargeAmountUsdCents; // 1:1 for PayRam
54
+ const creditCents = chargeAmountUsdCents; // 1:1 for crypto
57
55
  expect(creditCents).toBe(2500);
58
56
  // creditedCents must be 2500 (cents), not 25_000_000_000 (nanodollars)
59
57
  expect(creditCents).toBeLessThan(1_000_000);
@@ -61,7 +59,6 @@ describe("WOP-1058: PayRam cents/credits boundary", () => {
61
59
  it("cents-to-nanodollar scale difference is preserved as a sanity constant", () => {
62
60
  // Credit.SCALE = 1_000_000_000 nanodollars per dollar
63
61
  // 1 USD cent = 10_000_000 nanodollars (SCALE / 100)
64
- // This test documents the relationship so future developers understand the gap.
65
62
  const CREDIT_SCALE = 1_000_000_000;
66
63
  const CENTS_PER_DOLLAR = 100;
67
64
  const NANODOLLARS_PER_CENT = CREDIT_SCALE / CENTS_PER_DOLLAR;
@@ -0,0 +1,68 @@
1
+ import type { PlatformDb } from "../../db/index.js";
2
+ import type { CryptoPaymentState } from "./types.js";
3
+ export interface CryptoChargeRecord {
4
+ referenceId: string;
5
+ tenantId: string;
6
+ amountUsdCents: number;
7
+ status: string;
8
+ currency: string | null;
9
+ filledAmount: string | null;
10
+ creditedAt: string | null;
11
+ createdAt: string;
12
+ updatedAt: string;
13
+ chain: string | null;
14
+ token: string | null;
15
+ depositAddress: string | null;
16
+ derivationIndex: number | null;
17
+ }
18
+ export interface StablecoinChargeInput {
19
+ referenceId: string;
20
+ tenantId: string;
21
+ amountUsdCents: number;
22
+ chain: string;
23
+ token: string;
24
+ depositAddress: string;
25
+ derivationIndex: number;
26
+ }
27
+ export interface ICryptoChargeRepository {
28
+ create(referenceId: string, tenantId: string, amountUsdCents: number): Promise<void>;
29
+ getByReferenceId(referenceId: string): Promise<CryptoChargeRecord | null>;
30
+ updateStatus(referenceId: string, status: CryptoPaymentState, currency?: string, filledAmount?: string): Promise<void>;
31
+ markCredited(referenceId: string): Promise<void>;
32
+ isCredited(referenceId: string): Promise<boolean>;
33
+ createStablecoinCharge(input: StablecoinChargeInput): Promise<void>;
34
+ getByDepositAddress(address: string): Promise<CryptoChargeRecord | null>;
35
+ getNextDerivationIndex(): Promise<number>;
36
+ }
37
+ /**
38
+ * Manages crypto charge records in PostgreSQL.
39
+ *
40
+ * Each charge maps a BTCPay invoice ID to a tenant and tracks
41
+ * the payment lifecycle (New → Processing → Settled/Expired/Invalid).
42
+ *
43
+ * amountUsdCents stores the requested amount in USD cents (integer).
44
+ * This is NOT nanodollars — Credit.fromCents() handles the conversion
45
+ * when crediting the ledger in the webhook handler.
46
+ */
47
+ export declare class DrizzleCryptoChargeRepository implements ICryptoChargeRepository {
48
+ private readonly db;
49
+ constructor(db: PlatformDb);
50
+ /** Create a new charge record when an invoice is created. */
51
+ create(referenceId: string, tenantId: string, amountUsdCents: number): Promise<void>;
52
+ /** Get a charge by reference ID. Returns null if not found. */
53
+ getByReferenceId(referenceId: string): Promise<CryptoChargeRecord | null>;
54
+ private toRecord;
55
+ /** Update charge status and payment details from webhook. */
56
+ updateStatus(referenceId: string, status: CryptoPaymentState, currency?: string, filledAmount?: string): Promise<void>;
57
+ /** Mark a charge as credited (idempotency flag). */
58
+ markCredited(referenceId: string): Promise<void>;
59
+ /** Check if a charge has already been credited (for idempotency). */
60
+ isCredited(referenceId: string): Promise<boolean>;
61
+ /** Create a stablecoin charge with chain/token/deposit address. */
62
+ createStablecoinCharge(input: StablecoinChargeInput): Promise<void>;
63
+ /** Look up a charge by its deposit address. */
64
+ getByDepositAddress(address: string): Promise<CryptoChargeRecord | null>;
65
+ /** Get the next available HD derivation index (max + 1, or 0 if empty). */
66
+ getNextDerivationIndex(): Promise<number>;
67
+ }
68
+ export { DrizzleCryptoChargeRepository as CryptoChargeRepository };
@@ -0,0 +1,109 @@
1
+ import { eq, sql } from "drizzle-orm";
2
+ import { cryptoCharges } from "../../db/schema/crypto.js";
3
+ /**
4
+ * Manages crypto charge records in PostgreSQL.
5
+ *
6
+ * Each charge maps a BTCPay invoice ID to a tenant and tracks
7
+ * the payment lifecycle (New → Processing → Settled/Expired/Invalid).
8
+ *
9
+ * amountUsdCents stores the requested amount in USD cents (integer).
10
+ * This is NOT nanodollars — Credit.fromCents() handles the conversion
11
+ * when crediting the ledger in the webhook handler.
12
+ */
13
+ export class DrizzleCryptoChargeRepository {
14
+ db;
15
+ constructor(db) {
16
+ this.db = db;
17
+ }
18
+ /** Create a new charge record when an invoice is created. */
19
+ async create(referenceId, tenantId, amountUsdCents) {
20
+ await this.db.insert(cryptoCharges).values({
21
+ referenceId,
22
+ tenantId,
23
+ amountUsdCents,
24
+ status: "New",
25
+ });
26
+ }
27
+ /** Get a charge by reference ID. Returns null if not found. */
28
+ async getByReferenceId(referenceId) {
29
+ const row = (await this.db.select().from(cryptoCharges).where(eq(cryptoCharges.referenceId, referenceId)))[0];
30
+ if (!row)
31
+ return null;
32
+ return this.toRecord(row);
33
+ }
34
+ toRecord(row) {
35
+ return {
36
+ referenceId: row.referenceId,
37
+ tenantId: row.tenantId,
38
+ amountUsdCents: row.amountUsdCents,
39
+ status: row.status,
40
+ currency: row.currency ?? null,
41
+ filledAmount: row.filledAmount ?? null,
42
+ creditedAt: row.creditedAt ?? null,
43
+ createdAt: row.createdAt,
44
+ updatedAt: row.updatedAt,
45
+ chain: row.chain ?? null,
46
+ token: row.token ?? null,
47
+ depositAddress: row.depositAddress ?? null,
48
+ derivationIndex: row.derivationIndex ?? null,
49
+ };
50
+ }
51
+ /** Update charge status and payment details from webhook. */
52
+ async updateStatus(referenceId, status, currency, filledAmount) {
53
+ await this.db
54
+ .update(cryptoCharges)
55
+ .set({
56
+ status,
57
+ currency,
58
+ filledAmount,
59
+ updatedAt: sql `now()`,
60
+ })
61
+ .where(eq(cryptoCharges.referenceId, referenceId));
62
+ }
63
+ /** Mark a charge as credited (idempotency flag). */
64
+ async markCredited(referenceId) {
65
+ await this.db
66
+ .update(cryptoCharges)
67
+ .set({
68
+ creditedAt: sql `now()`,
69
+ updatedAt: sql `now()`,
70
+ })
71
+ .where(eq(cryptoCharges.referenceId, referenceId));
72
+ }
73
+ /** Check if a charge has already been credited (for idempotency). */
74
+ async isCredited(referenceId) {
75
+ const row = (await this.db
76
+ .select({ creditedAt: cryptoCharges.creditedAt })
77
+ .from(cryptoCharges)
78
+ .where(eq(cryptoCharges.referenceId, referenceId)))[0];
79
+ return row?.creditedAt != null;
80
+ }
81
+ /** Create a stablecoin charge with chain/token/deposit address. */
82
+ async createStablecoinCharge(input) {
83
+ await this.db.insert(cryptoCharges).values({
84
+ referenceId: input.referenceId,
85
+ tenantId: input.tenantId,
86
+ amountUsdCents: input.amountUsdCents,
87
+ status: "New",
88
+ chain: input.chain,
89
+ token: input.token,
90
+ depositAddress: input.depositAddress,
91
+ derivationIndex: input.derivationIndex,
92
+ });
93
+ }
94
+ /** Look up a charge by its deposit address. */
95
+ async getByDepositAddress(address) {
96
+ const row = (await this.db.select().from(cryptoCharges).where(eq(cryptoCharges.depositAddress, address)))[0];
97
+ if (!row)
98
+ return null;
99
+ return this.toRecord(row);
100
+ }
101
+ /** Get the next available HD derivation index (max + 1, or 0 if empty). */
102
+ async getNextDerivationIndex() {
103
+ const result = await this.db
104
+ .select({ maxIdx: sql `coalesce(max(${cryptoCharges.derivationIndex}), -1)` })
105
+ .from(cryptoCharges);
106
+ return (result[0]?.maxIdx ?? -1) + 1;
107
+ }
108
+ }
109
+ export { DrizzleCryptoChargeRepository as CryptoChargeRepository };
@@ -0,0 +1,120 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
2
+ import { beginTestTransaction, createTestDb, endTestTransaction, rollbackTestTransaction } from "../../test/db.js";
3
+ import { CryptoChargeRepository } from "./charge-store.js";
4
+ describe("CryptoChargeRepository", () => {
5
+ let pool;
6
+ let db;
7
+ let store;
8
+ beforeAll(async () => {
9
+ ({ db, pool } = await createTestDb());
10
+ await beginTestTransaction(pool);
11
+ });
12
+ afterAll(async () => {
13
+ await endTestTransaction(pool);
14
+ await pool.close();
15
+ });
16
+ beforeEach(async () => {
17
+ await rollbackTestTransaction(pool);
18
+ store = new CryptoChargeRepository(db);
19
+ });
20
+ it("create() stores a charge with New status", async () => {
21
+ await store.create("inv-001", "tenant-1", 2500);
22
+ const charge = await store.getByReferenceId("inv-001");
23
+ expect(charge).not.toBeNull();
24
+ expect(charge?.referenceId).toBe("inv-001");
25
+ expect(charge?.tenantId).toBe("tenant-1");
26
+ expect(charge?.amountUsdCents).toBe(2500);
27
+ expect(charge?.status).toBe("New");
28
+ expect(charge?.creditedAt).toBeNull();
29
+ });
30
+ it("getByReferenceId() returns null when not found", async () => {
31
+ const charge = await store.getByReferenceId("inv-nonexistent");
32
+ expect(charge).toBeNull();
33
+ });
34
+ it("updateStatus() updates status, currency and filled_amount", async () => {
35
+ await store.create("inv-002", "tenant-2", 5000);
36
+ await store.updateStatus("inv-002", "Settled", "BTC", "0.00025");
37
+ const charge = await store.getByReferenceId("inv-002");
38
+ expect(charge?.status).toBe("Settled");
39
+ expect(charge?.currency).toBe("BTC");
40
+ expect(charge?.filledAmount).toBe("0.00025");
41
+ });
42
+ it("updateStatus() handles partial updates (no currency)", async () => {
43
+ await store.create("inv-003", "tenant-3", 1000);
44
+ await store.updateStatus("inv-003", "Processing");
45
+ const charge = await store.getByReferenceId("inv-003");
46
+ expect(charge?.status).toBe("Processing");
47
+ expect(charge?.currency).toBeNull();
48
+ });
49
+ it("isCredited() returns false before markCredited", async () => {
50
+ await store.create("inv-004", "tenant-4", 1500);
51
+ expect(await store.isCredited("inv-004")).toBe(false);
52
+ });
53
+ it("markCredited() sets creditedAt", async () => {
54
+ await store.create("inv-005", "tenant-5", 3000);
55
+ await store.markCredited("inv-005");
56
+ const charge = await store.getByReferenceId("inv-005");
57
+ expect(charge?.creditedAt).not.toBeNull();
58
+ });
59
+ it("isCredited() returns true after markCredited", async () => {
60
+ await store.create("inv-006", "tenant-6", 2000);
61
+ await store.markCredited("inv-006");
62
+ expect(await store.isCredited("inv-006")).toBe(true);
63
+ });
64
+ describe("stablecoin charges", () => {
65
+ it("creates a stablecoin charge with chain/token/address", async () => {
66
+ await store.createStablecoinCharge({
67
+ referenceId: "sc:base:usdc:0x123",
68
+ tenantId: "tenant-1",
69
+ amountUsdCents: 1000,
70
+ chain: "base",
71
+ token: "USDC",
72
+ depositAddress: "0xabc123",
73
+ derivationIndex: 42,
74
+ });
75
+ const charge = await store.getByReferenceId("sc:base:usdc:0x123");
76
+ expect(charge).not.toBeNull();
77
+ expect(charge?.chain).toBe("base");
78
+ expect(charge?.token).toBe("USDC");
79
+ expect(charge?.depositAddress).toBe("0xabc123");
80
+ expect(charge?.derivationIndex).toBe(42);
81
+ expect(charge?.amountUsdCents).toBe(1000);
82
+ });
83
+ it("looks up charge by deposit address", async () => {
84
+ await store.createStablecoinCharge({
85
+ referenceId: "sc:base:usdc:0x456",
86
+ tenantId: "tenant-2",
87
+ amountUsdCents: 5000,
88
+ chain: "base",
89
+ token: "USDC",
90
+ depositAddress: "0xdef456",
91
+ derivationIndex: 43,
92
+ });
93
+ const charge = await store.getByDepositAddress("0xdef456");
94
+ expect(charge).not.toBeNull();
95
+ expect(charge?.tenantId).toBe("tenant-2");
96
+ expect(charge?.amountUsdCents).toBe(5000);
97
+ });
98
+ it("returns null for unknown deposit address", async () => {
99
+ const charge = await store.getByDepositAddress("0xnonexistent");
100
+ expect(charge).toBeNull();
101
+ });
102
+ it("gets next derivation index (0 when empty)", async () => {
103
+ const idx = await store.getNextDerivationIndex();
104
+ expect(idx).toBe(0);
105
+ });
106
+ it("gets next derivation index (max + 1)", async () => {
107
+ await store.createStablecoinCharge({
108
+ referenceId: "sc:idx-test",
109
+ tenantId: "t",
110
+ amountUsdCents: 100,
111
+ chain: "base",
112
+ token: "USDC",
113
+ depositAddress: "0xidxtest",
114
+ derivationIndex: 5,
115
+ });
116
+ const idx = await store.getNextDerivationIndex();
117
+ expect(idx).toBe(6);
118
+ });
119
+ });
120
+ });
@@ -0,0 +1,18 @@
1
+ import type { ICryptoChargeRepository } from "./charge-store.js";
2
+ import type { BTCPayClient } from "./client.js";
3
+ import type { CryptoCheckoutOpts } from "./types.js";
4
+ /** Minimum payment amount in USD. */
5
+ export declare const MIN_PAYMENT_USD = 10;
6
+ /**
7
+ * Create a BTCPay invoice and store the charge record.
8
+ *
9
+ * Returns the BTCPay-hosted checkout page URL and invoice ID.
10
+ * The user is redirected to checkoutLink to complete the crypto payment.
11
+ *
12
+ * NOTE: amountUsd is converted to cents (integer) for the charge store.
13
+ * The charge store holds USD cents, NOT nanodollars.
14
+ */
15
+ export declare function createCryptoCheckout(client: BTCPayClient, chargeStore: ICryptoChargeRepository, opts: CryptoCheckoutOpts): Promise<{
16
+ referenceId: string;
17
+ url: string;
18
+ }>;
@@ -0,0 +1,35 @@
1
+ import crypto from "node:crypto";
2
+ import { Credit } from "../../credits/credit.js";
3
+ /** Minimum payment amount in USD. */
4
+ export const MIN_PAYMENT_USD = 10;
5
+ /**
6
+ * Create a BTCPay invoice and store the charge record.
7
+ *
8
+ * Returns the BTCPay-hosted checkout page URL and invoice ID.
9
+ * The user is redirected to checkoutLink to complete the crypto payment.
10
+ *
11
+ * NOTE: amountUsd is converted to cents (integer) for the charge store.
12
+ * The charge store holds USD cents, NOT nanodollars.
13
+ */
14
+ export async function createCryptoCheckout(client, chargeStore, opts) {
15
+ if (opts.amountUsd < MIN_PAYMENT_USD) {
16
+ throw new Error(`Minimum payment amount is $${MIN_PAYMENT_USD}`);
17
+ }
18
+ const orderId = `crypto:${opts.tenant}:${crypto.randomUUID()}`;
19
+ const invoice = await client.createInvoice({
20
+ amountUsd: opts.amountUsd,
21
+ orderId,
22
+ buyerEmail: `${opts.tenant}@${process.env.PLATFORM_DOMAIN ?? "wopr.bot"}`,
23
+ });
24
+ // Store the charge record for webhook correlation.
25
+ // amountUsdCents = USD * 100 (cents, NOT nanodollars).
26
+ // Credit.fromDollars() handles the float → integer boundary safely via Math.round
27
+ // on the nanodollar scale, then toCentsRounded() converts back to integer cents.
28
+ // This avoids direct floating-point multiplication for the cents conversion.
29
+ const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
30
+ await chargeStore.create(invoice.id, opts.tenant, amountUsdCents);
31
+ return {
32
+ referenceId: invoice.id,
33
+ url: invoice.checkoutLink,
34
+ };
35
+ }
@@ -0,0 +1,71 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { beginTestTransaction, createTestDb, endTestTransaction, rollbackTestTransaction } from "../../test/db.js";
3
+ import { CryptoChargeRepository } from "./charge-store.js";
4
+ import { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
5
+ function createMockClient(overrides = {}) {
6
+ return {
7
+ createInvoice: overrides.createInvoice ??
8
+ vi.fn().mockResolvedValue({
9
+ id: "inv-mock-001",
10
+ checkoutLink: "https://btcpay.example.com/i/inv-mock-001",
11
+ }),
12
+ };
13
+ }
14
+ describe("createCryptoCheckout", () => {
15
+ let pool;
16
+ let db;
17
+ let chargeStore;
18
+ let client;
19
+ beforeAll(async () => {
20
+ ({ db, pool } = await createTestDb());
21
+ await beginTestTransaction(pool);
22
+ });
23
+ afterAll(async () => {
24
+ await endTestTransaction(pool);
25
+ await pool.close();
26
+ });
27
+ beforeEach(async () => {
28
+ await rollbackTestTransaction(pool);
29
+ chargeStore = new CryptoChargeRepository(db);
30
+ client = createMockClient();
31
+ });
32
+ it("rejects amounts below $10 minimum", async () => {
33
+ await expect(createCryptoCheckout(client, chargeStore, { tenant: "t-1", amountUsd: 5 })).rejects.toThrow(`Minimum payment amount is $${MIN_PAYMENT_USD}`);
34
+ });
35
+ it("rejects amounts of exactly $0", async () => {
36
+ await expect(createCryptoCheckout(client, chargeStore, { tenant: "t-1", amountUsd: 0 })).rejects.toThrow();
37
+ });
38
+ it("calls client.createInvoice with correct params", async () => {
39
+ const createInvoice = vi.fn().mockResolvedValue({
40
+ id: "inv-abc",
41
+ checkoutLink: "https://btcpay.example.com/i/inv-abc",
42
+ });
43
+ const mockClient = createMockClient({ createInvoice });
44
+ await createCryptoCheckout(mockClient, chargeStore, { tenant: "t-test", amountUsd: 25 });
45
+ expect(createInvoice).toHaveBeenCalledOnce();
46
+ const args = createInvoice.mock.calls[0][0];
47
+ expect(args.amountUsd).toBe(25);
48
+ expect(args.buyerEmail).toContain("t-test@");
49
+ });
50
+ it("stores the charge with correct amountUsdCents (converts from USD)", async () => {
51
+ const createInvoice = vi.fn().mockResolvedValue({
52
+ id: "inv-store-test",
53
+ checkoutLink: "https://btcpay.example.com/i/inv-store-test",
54
+ });
55
+ const mockClient = createMockClient({ createInvoice });
56
+ await createCryptoCheckout(mockClient, chargeStore, { tenant: "t-2", amountUsd: 25 });
57
+ const charge = await chargeStore.getByReferenceId("inv-store-test");
58
+ expect(charge).not.toBeNull();
59
+ expect(charge?.tenantId).toBe("t-2");
60
+ expect(charge?.amountUsdCents).toBe(2500); // $25.00 = 2500 cents
61
+ expect(charge?.status).toBe("New");
62
+ });
63
+ it("returns referenceId and url", async () => {
64
+ const result = await createCryptoCheckout(client, chargeStore, { tenant: "t-3", amountUsd: 10 });
65
+ expect(result.referenceId).toBe("inv-mock-001");
66
+ expect(result.url).toBe("https://btcpay.example.com/i/inv-mock-001");
67
+ });
68
+ it("accepts exactly $10 (minimum boundary)", async () => {
69
+ await expect(createCryptoCheckout(client, chargeStore, { tenant: "t-4", amountUsd: 10 })).resolves.not.toBeNull();
70
+ });
71
+ });
@@ -0,0 +1,39 @@
1
+ import type { CryptoBillingConfig } from "./types.js";
2
+ export type { CryptoBillingConfig as CryptoConfig };
3
+ /**
4
+ * Lightweight BTCPay Server Greenfield API client.
5
+ *
6
+ * Uses plain fetch — zero vendor dependencies.
7
+ * Auth header format: "token <apiKey>" (NOT "Bearer").
8
+ */
9
+ export declare class BTCPayClient {
10
+ private readonly config;
11
+ constructor(config: CryptoBillingConfig);
12
+ private headers;
13
+ /**
14
+ * Create an invoice on the BTCPay store.
15
+ *
16
+ * Returns the invoice ID and checkout link (URL to redirect the user).
17
+ */
18
+ createInvoice(opts: {
19
+ amountUsd: number;
20
+ orderId: string;
21
+ buyerEmail?: string;
22
+ redirectURL?: string;
23
+ }): Promise<{
24
+ id: string;
25
+ checkoutLink: string;
26
+ }>;
27
+ /** Get invoice status by ID. */
28
+ getInvoice(invoiceId: string): Promise<{
29
+ id: string;
30
+ status: string;
31
+ amount: string;
32
+ currency: string;
33
+ }>;
34
+ }
35
+ /**
36
+ * Load BTCPay config from environment variables.
37
+ * Returns null if any required var is missing.
38
+ */
39
+ export declare function loadCryptoConfig(): CryptoBillingConfig | null;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Lightweight BTCPay Server Greenfield API client.
3
+ *
4
+ * Uses plain fetch — zero vendor dependencies.
5
+ * Auth header format: "token <apiKey>" (NOT "Bearer").
6
+ */
7
+ export class BTCPayClient {
8
+ config;
9
+ constructor(config) {
10
+ this.config = config;
11
+ }
12
+ headers() {
13
+ return {
14
+ "Content-Type": "application/json",
15
+ Authorization: `token ${this.config.apiKey}`,
16
+ };
17
+ }
18
+ /**
19
+ * Create an invoice on the BTCPay store.
20
+ *
21
+ * Returns the invoice ID and checkout link (URL to redirect the user).
22
+ */
23
+ async createInvoice(opts) {
24
+ const url = `${this.config.baseUrl}/api/v1/stores/${this.config.storeId}/invoices`;
25
+ const body = {
26
+ amount: String(opts.amountUsd),
27
+ currency: "USD",
28
+ metadata: {
29
+ orderId: opts.orderId,
30
+ buyerEmail: opts.buyerEmail,
31
+ },
32
+ checkout: {
33
+ speedPolicy: "MediumSpeed",
34
+ expirationMinutes: 30,
35
+ ...(opts.redirectURL ? { redirectURL: opts.redirectURL } : {}),
36
+ },
37
+ };
38
+ const res = await fetch(url, {
39
+ method: "POST",
40
+ headers: this.headers(),
41
+ body: JSON.stringify(body),
42
+ });
43
+ if (!res.ok) {
44
+ const text = await res.text().catch(() => "");
45
+ throw new Error(`BTCPay createInvoice failed (${res.status}): ${text}`);
46
+ }
47
+ const data = (await res.json());
48
+ return { id: data.id, checkoutLink: data.checkoutLink };
49
+ }
50
+ /** Get invoice status by ID. */
51
+ async getInvoice(invoiceId) {
52
+ const url = `${this.config.baseUrl}/api/v1/stores/${this.config.storeId}/invoices/${invoiceId}`;
53
+ const res = await fetch(url, { headers: this.headers() });
54
+ if (!res.ok) {
55
+ const text = await res.text().catch(() => "");
56
+ throw new Error(`BTCPay getInvoice failed (${res.status}): ${text}`);
57
+ }
58
+ return (await res.json());
59
+ }
60
+ }
61
+ /**
62
+ * Load BTCPay config from environment variables.
63
+ * Returns null if any required var is missing.
64
+ */
65
+ export function loadCryptoConfig() {
66
+ const apiKey = process.env.BTCPAY_API_KEY;
67
+ const baseUrl = process.env.BTCPAY_BASE_URL;
68
+ const storeId = process.env.BTCPAY_STORE_ID;
69
+ if (!apiKey || !baseUrl || !storeId)
70
+ return null;
71
+ return { apiKey, baseUrl, storeId };
72
+ }