@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.
- package/dist/account/deletion-executor-repository.d.ts +2 -2
- package/dist/account/deletion-executor-repository.js +5 -5
- package/dist/{monetization/payram → billing/crypto}/cents-credits-boundary.test.js +14 -17
- package/dist/billing/{payram → crypto}/charge-store.d.ts +17 -13
- package/dist/billing/{payram → crypto}/charge-store.js +21 -18
- package/dist/billing/crypto/charge-store.test.js +64 -0
- package/dist/billing/crypto/checkout.d.ts +18 -0
- package/dist/billing/crypto/checkout.js +35 -0
- package/dist/billing/crypto/checkout.test.js +71 -0
- package/dist/billing/crypto/client.d.ts +39 -0
- package/dist/billing/crypto/client.js +72 -0
- package/dist/billing/crypto/client.test.js +100 -0
- package/dist/billing/crypto/index.d.ts +9 -0
- package/dist/billing/crypto/index.js +5 -0
- package/dist/billing/crypto/types.d.ts +61 -0
- package/dist/billing/crypto/types.js +24 -0
- package/dist/billing/crypto/webhook.d.ts +34 -0
- package/dist/billing/crypto/webhook.js +107 -0
- package/dist/billing/crypto/webhook.test.js +266 -0
- package/dist/billing/index.d.ts +1 -1
- package/dist/billing/index.js +2 -2
- package/dist/billing/payment-processor.d.ts +3 -3
- package/dist/credits/credit-ledger.d.ts +3 -3
- package/dist/credits/credit-ledger.js +3 -3
- package/dist/db/schema/credits.js +1 -1
- package/dist/db/schema/{payram.d.ts → crypto.d.ts} +17 -13
- package/dist/db/schema/crypto.js +25 -0
- package/dist/db/schema/index.d.ts +1 -1
- package/dist/db/schema/index.js +1 -1
- package/dist/monetization/crypto/__tests__/webhook.test.js +249 -0
- package/dist/monetization/crypto/index.d.ts +4 -0
- package/dist/monetization/crypto/index.js +2 -0
- package/dist/monetization/crypto/webhook.d.ts +24 -0
- package/dist/monetization/crypto/webhook.js +88 -0
- package/dist/monetization/index.d.ts +3 -3
- package/dist/monetization/index.js +1 -1
- package/dist/monetization/repository-types.d.ts +1 -1
- package/dist/observability/pagerduty.test.js +1 -0
- package/drizzle/migrations/0004_crypto_charges.sql +25 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -3
- package/src/account/deletion-executor-repository.ts +6 -6
- package/src/billing/{payram → crypto}/cents-credits-boundary.test.ts +14 -17
- package/src/billing/crypto/charge-store.test.ts +81 -0
- package/src/billing/{payram → crypto}/charge-store.ts +28 -25
- package/src/billing/crypto/checkout.test.ts +93 -0
- package/src/billing/crypto/checkout.ts +48 -0
- package/src/billing/crypto/client.test.ts +132 -0
- package/src/billing/crypto/client.ts +86 -0
- package/src/billing/crypto/index.ts +15 -0
- package/src/billing/crypto/types.ts +83 -0
- package/src/billing/crypto/webhook.test.ts +340 -0
- package/src/billing/crypto/webhook.ts +136 -0
- package/src/billing/index.ts +2 -2
- package/src/billing/payment-processor.ts +3 -3
- package/src/credits/credit-ledger.ts +3 -3
- package/src/db/schema/credits.ts +1 -1
- package/src/db/schema/crypto.ts +30 -0
- package/src/db/schema/index.ts +1 -1
- package/src/monetization/crypto/__tests__/webhook.test.ts +327 -0
- package/src/monetization/crypto/index.ts +23 -0
- package/src/monetization/crypto/webhook.ts +115 -0
- package/src/monetization/index.ts +23 -21
- package/src/monetization/repository-types.ts +2 -2
- package/src/observability/pagerduty.test.ts +1 -0
- package/dist/billing/payram/cents-credits-boundary.test.js +0 -75
- package/dist/billing/payram/charge-store.test.js +0 -64
- package/dist/billing/payram/checkout.d.ts +0 -15
- package/dist/billing/payram/checkout.js +0 -24
- package/dist/billing/payram/checkout.test.js +0 -74
- package/dist/billing/payram/client.d.ts +0 -7
- package/dist/billing/payram/client.js +0 -15
- package/dist/billing/payram/client.test.js +0 -52
- package/dist/billing/payram/index.d.ts +0 -8
- package/dist/billing/payram/index.js +0 -4
- package/dist/billing/payram/types.d.ts +0 -40
- package/dist/billing/payram/webhook.d.ts +0 -19
- package/dist/billing/payram/webhook.js +0 -71
- package/dist/billing/payram/webhook.test.d.ts +0 -7
- package/dist/billing/payram/webhook.test.js +0 -249
- package/dist/db/schema/payram.js +0 -21
- package/dist/monetization/payram/charge-store.test.d.ts +0 -1
- package/dist/monetization/payram/charge-store.test.js +0 -64
- package/dist/monetization/payram/checkout.test.d.ts +0 -1
- package/dist/monetization/payram/checkout.test.js +0 -73
- package/dist/monetization/payram/client.test.d.ts +0 -1
- package/dist/monetization/payram/client.test.js +0 -52
- package/dist/monetization/payram/index.d.ts +0 -4
- package/dist/monetization/payram/index.js +0 -2
- package/dist/monetization/payram/webhook.d.ts +0 -17
- package/dist/monetization/payram/webhook.js +0 -71
- package/dist/monetization/payram/webhook.test.d.ts +0 -7
- package/dist/monetization/payram/webhook.test.js +0 -247
- package/src/billing/payram/charge-store.test.ts +0 -84
- package/src/billing/payram/checkout.test.ts +0 -99
- package/src/billing/payram/checkout.ts +0 -40
- package/src/billing/payram/client.test.ts +0 -62
- package/src/billing/payram/client.ts +0 -21
- package/src/billing/payram/index.ts +0 -14
- package/src/billing/payram/types.ts +0 -44
- package/src/billing/payram/webhook.test.ts +0 -320
- package/src/billing/payram/webhook.ts +0 -94
- package/src/db/schema/payram.ts +0 -26
- package/src/monetization/payram/cents-credits-boundary.test.ts +0 -84
- package/src/monetization/payram/charge-store.test.ts +0 -84
- package/src/monetization/payram/checkout.test.ts +0 -98
- package/src/monetization/payram/client.test.ts +0 -62
- package/src/monetization/payram/index.ts +0 -20
- package/src/monetization/payram/webhook.test.ts +0 -327
- package/src/monetization/payram/webhook.ts +0 -97
- /package/dist/billing/{payram → crypto}/cents-credits-boundary.test.d.ts +0 -0
- /package/dist/billing/{payram → crypto}/charge-store.test.d.ts +0 -0
- /package/dist/billing/{payram → crypto}/checkout.test.d.ts +0 -0
- /package/dist/billing/{payram → crypto}/client.test.d.ts +0 -0
- /package/dist/billing/{payram/types.js → crypto/webhook.test.d.ts} +0 -0
- /package/dist/monetization/{payram/cents-credits-boundary.test.d.ts → crypto/__tests__/webhook.test.d.ts} +0 -0
|
@@ -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");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wopr-network/platform-core",
|
|
3
|
-
"version": "1.
|
|
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
|
-
|
|
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
|
|
262
|
+
async deleteCryptoCharges(tenantId: string): Promise<number> {
|
|
263
263
|
const result = await this.db
|
|
264
|
-
.delete(
|
|
265
|
-
.where(eq(
|
|
266
|
-
.returning({ 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
|
|
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/
|
|
12
|
+
* USD cents. See src/credits/credit-ledger.ts for naming convention.
|
|
12
13
|
*/
|
|
13
|
-
describe("
|
|
14
|
-
it("USD to cents conversion is correct (mirrors checkout.ts pattern)", () => {
|
|
15
|
-
// This mirrors the conversion in
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
59
|
-
//
|
|
60
|
-
// The credited amount always equals the stored USD cents — no bonus tiers for
|
|
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
|
|
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 {
|
|
4
|
-
import type {
|
|
3
|
+
import { cryptoCharges } from "../../db/schema/crypto.js";
|
|
4
|
+
import type { CryptoPaymentState } from "./types.js";
|
|
5
5
|
|
|
6
|
-
export interface
|
|
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
|
|
18
|
+
export interface ICryptoChargeRepository {
|
|
19
19
|
create(referenceId: string, tenantId: string, amountUsdCents: number): Promise<void>;
|
|
20
|
-
getByReferenceId(referenceId: string): Promise<
|
|
20
|
+
getByReferenceId(referenceId: string): Promise<CryptoChargeRecord | null>;
|
|
21
21
|
updateStatus(
|
|
22
22
|
referenceId: string,
|
|
23
|
-
status:
|
|
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
|
|
32
|
+
* Manages crypto charge records in PostgreSQL.
|
|
33
33
|
*
|
|
34
|
-
* Each charge maps a
|
|
35
|
-
* the payment lifecycle (
|
|
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
|
|
41
|
+
export class DrizzleCryptoChargeRepository implements ICryptoChargeRepository {
|
|
38
42
|
constructor(private readonly db: PlatformDb) {}
|
|
39
43
|
|
|
40
|
-
/** Create a new charge record when
|
|
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(
|
|
46
|
+
await this.db.insert(cryptoCharges).values({
|
|
43
47
|
referenceId,
|
|
44
48
|
tenantId,
|
|
45
49
|
amountUsdCents,
|
|
46
|
-
status: "
|
|
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<
|
|
52
|
-
const row = (await this.db.select().from(
|
|
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:
|
|
74
|
+
status: CryptoPaymentState,
|
|
71
75
|
currency?: string,
|
|
72
76
|
filledAmount?: string,
|
|
73
77
|
): Promise<void> {
|
|
74
78
|
await this.db
|
|
75
|
-
.update(
|
|
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(
|
|
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(
|
|
92
|
+
.update(cryptoCharges)
|
|
89
93
|
.set({
|
|
90
94
|
creditedAt: sql`now()`,
|
|
91
95
|
updatedAt: sql`now()`,
|
|
92
96
|
})
|
|
93
|
-
.where(eq(
|
|
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:
|
|
101
|
-
.from(
|
|
102
|
-
.where(eq(
|
|
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
|
-
|
|
109
|
-
export { DrizzlePayRamChargeRepository as PayRamChargeRepository };
|
|
112
|
+
export { DrizzleCryptoChargeRepository as CryptoChargeRepository };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { PGlite } from "@electric-sql/pglite";
|
|
2
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } 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
|
+
import { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
|
|
7
|
+
import type { BTCPayClient } from "./client.js";
|
|
8
|
+
|
|
9
|
+
function createMockClient(overrides: { createInvoice?: ReturnType<typeof vi.fn> } = {}): BTCPayClient {
|
|
10
|
+
return {
|
|
11
|
+
createInvoice:
|
|
12
|
+
overrides.createInvoice ??
|
|
13
|
+
vi.fn().mockResolvedValue({
|
|
14
|
+
id: "inv-mock-001",
|
|
15
|
+
checkoutLink: "https://btcpay.example.com/i/inv-mock-001",
|
|
16
|
+
}),
|
|
17
|
+
} as unknown as BTCPayClient;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("createCryptoCheckout", () => {
|
|
21
|
+
let pool: PGlite;
|
|
22
|
+
let db: PlatformDb;
|
|
23
|
+
let chargeStore: CryptoChargeRepository;
|
|
24
|
+
let client: BTCPayClient;
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
({ db, pool } = await createTestDb());
|
|
28
|
+
await beginTestTransaction(pool);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(async () => {
|
|
32
|
+
await endTestTransaction(pool);
|
|
33
|
+
await pool.close();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
await rollbackTestTransaction(pool);
|
|
38
|
+
chargeStore = new CryptoChargeRepository(db);
|
|
39
|
+
client = createMockClient();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("rejects amounts below $10 minimum", async () => {
|
|
43
|
+
await expect(createCryptoCheckout(client, chargeStore, { tenant: "t-1", amountUsd: 5 })).rejects.toThrow(
|
|
44
|
+
`Minimum payment amount is $${MIN_PAYMENT_USD}`,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("rejects amounts of exactly $0", async () => {
|
|
49
|
+
await expect(createCryptoCheckout(client, chargeStore, { tenant: "t-1", amountUsd: 0 })).rejects.toThrow();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("calls client.createInvoice with correct params", async () => {
|
|
53
|
+
const createInvoice = vi.fn().mockResolvedValue({
|
|
54
|
+
id: "inv-abc",
|
|
55
|
+
checkoutLink: "https://btcpay.example.com/i/inv-abc",
|
|
56
|
+
});
|
|
57
|
+
const mockClient = createMockClient({ createInvoice });
|
|
58
|
+
|
|
59
|
+
await createCryptoCheckout(mockClient, chargeStore, { tenant: "t-test", amountUsd: 25 });
|
|
60
|
+
|
|
61
|
+
expect(createInvoice).toHaveBeenCalledOnce();
|
|
62
|
+
const args = createInvoice.mock.calls[0][0];
|
|
63
|
+
expect(args.amountUsd).toBe(25);
|
|
64
|
+
expect(args.buyerEmail).toContain("t-test@");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("stores the charge with correct amountUsdCents (converts from USD)", async () => {
|
|
68
|
+
const createInvoice = vi.fn().mockResolvedValue({
|
|
69
|
+
id: "inv-store-test",
|
|
70
|
+
checkoutLink: "https://btcpay.example.com/i/inv-store-test",
|
|
71
|
+
});
|
|
72
|
+
const mockClient = createMockClient({ createInvoice });
|
|
73
|
+
|
|
74
|
+
await createCryptoCheckout(mockClient, chargeStore, { tenant: "t-2", amountUsd: 25 });
|
|
75
|
+
|
|
76
|
+
const charge = await chargeStore.getByReferenceId("inv-store-test");
|
|
77
|
+
expect(charge).not.toBeNull();
|
|
78
|
+
expect(charge?.tenantId).toBe("t-2");
|
|
79
|
+
expect(charge?.amountUsdCents).toBe(2500); // $25.00 = 2500 cents
|
|
80
|
+
expect(charge?.status).toBe("New");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns referenceId and url", async () => {
|
|
84
|
+
const result = await createCryptoCheckout(client, chargeStore, { tenant: "t-3", amountUsd: 10 });
|
|
85
|
+
|
|
86
|
+
expect(result.referenceId).toBe("inv-mock-001");
|
|
87
|
+
expect(result.url).toBe("https://btcpay.example.com/i/inv-mock-001");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("accepts exactly $10 (minimum boundary)", async () => {
|
|
91
|
+
await expect(createCryptoCheckout(client, chargeStore, { tenant: "t-4", amountUsd: 10 })).resolves.not.toBeNull();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { Credit } from "../../credits/credit.js";
|
|
3
|
+
import type { ICryptoChargeRepository } from "./charge-store.js";
|
|
4
|
+
import type { BTCPayClient } from "./client.js";
|
|
5
|
+
import type { CryptoCheckoutOpts } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/** Minimum payment amount in USD. */
|
|
8
|
+
export const MIN_PAYMENT_USD = 10;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a BTCPay invoice and store the charge record.
|
|
12
|
+
*
|
|
13
|
+
* Returns the BTCPay-hosted checkout page URL and invoice ID.
|
|
14
|
+
* The user is redirected to checkoutLink to complete the crypto payment.
|
|
15
|
+
*
|
|
16
|
+
* NOTE: amountUsd is converted to cents (integer) for the charge store.
|
|
17
|
+
* The charge store holds USD cents, NOT nanodollars.
|
|
18
|
+
*/
|
|
19
|
+
export async function createCryptoCheckout(
|
|
20
|
+
client: BTCPayClient,
|
|
21
|
+
chargeStore: ICryptoChargeRepository,
|
|
22
|
+
opts: CryptoCheckoutOpts,
|
|
23
|
+
): Promise<{ referenceId: string; url: string }> {
|
|
24
|
+
if (opts.amountUsd < MIN_PAYMENT_USD) {
|
|
25
|
+
throw new Error(`Minimum payment amount is $${MIN_PAYMENT_USD}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const orderId = `crypto:${opts.tenant}:${crypto.randomUUID()}`;
|
|
29
|
+
|
|
30
|
+
const invoice = await client.createInvoice({
|
|
31
|
+
amountUsd: opts.amountUsd,
|
|
32
|
+
orderId,
|
|
33
|
+
buyerEmail: `${opts.tenant}@${process.env.PLATFORM_DOMAIN ?? "wopr.bot"}`,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Store the charge record for webhook correlation.
|
|
37
|
+
// amountUsdCents = USD * 100 (cents, NOT nanodollars).
|
|
38
|
+
// Credit.fromDollars() handles the float → integer boundary safely via Math.round
|
|
39
|
+
// on the nanodollar scale, then toCentsRounded() converts back to integer cents.
|
|
40
|
+
// This avoids direct floating-point multiplication for the cents conversion.
|
|
41
|
+
const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
|
|
42
|
+
await chargeStore.create(invoice.id, opts.tenant, amountUsdCents);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
referenceId: invoice.id,
|
|
46
|
+
url: invoice.checkoutLink,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { BTCPayClient, loadCryptoConfig } from "./client.js";
|
|
3
|
+
|
|
4
|
+
describe("BTCPayClient", () => {
|
|
5
|
+
it("createInvoice sends correct request and returns id + checkoutLink", async () => {
|
|
6
|
+
const mockResponse = { id: "inv-001", checkoutLink: "https://btcpay.example.com/i/inv-001" };
|
|
7
|
+
const fetchSpy = vi
|
|
8
|
+
.spyOn(globalThis, "fetch")
|
|
9
|
+
.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
|
|
10
|
+
|
|
11
|
+
const client = new BTCPayClient({
|
|
12
|
+
apiKey: "test-key",
|
|
13
|
+
baseUrl: "https://btcpay.example.com",
|
|
14
|
+
storeId: "store-abc",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const result = await client.createInvoice({
|
|
18
|
+
amountUsd: 25,
|
|
19
|
+
orderId: "order-123",
|
|
20
|
+
buyerEmail: "test@example.com",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(result.id).toBe("inv-001");
|
|
24
|
+
expect(result.checkoutLink).toBe("https://btcpay.example.com/i/inv-001");
|
|
25
|
+
|
|
26
|
+
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
27
|
+
const [url, opts] = fetchSpy.mock.calls[0];
|
|
28
|
+
expect(url).toBe("https://btcpay.example.com/api/v1/stores/store-abc/invoices");
|
|
29
|
+
expect(opts?.method).toBe("POST");
|
|
30
|
+
|
|
31
|
+
const headers = opts?.headers as Record<string, string>;
|
|
32
|
+
expect(headers.Authorization).toBe("token test-key");
|
|
33
|
+
expect(headers["Content-Type"]).toBe("application/json");
|
|
34
|
+
|
|
35
|
+
const body = JSON.parse(opts?.body as string);
|
|
36
|
+
expect(body.amount).toBe("25");
|
|
37
|
+
expect(body.currency).toBe("USD");
|
|
38
|
+
expect(body.metadata.orderId).toBe("order-123");
|
|
39
|
+
expect(body.metadata.buyerEmail).toBe("test@example.com");
|
|
40
|
+
expect(body.checkout.speedPolicy).toBe("MediumSpeed");
|
|
41
|
+
|
|
42
|
+
fetchSpy.mockRestore();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("createInvoice includes redirectURL when provided", async () => {
|
|
46
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
47
|
+
new Response(JSON.stringify({ id: "inv-002", checkoutLink: "https://btcpay.example.com/i/inv-002" }), {
|
|
48
|
+
status: 200,
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const client = new BTCPayClient({ apiKey: "k", baseUrl: "https://btcpay.example.com", storeId: "s" });
|
|
53
|
+
await client.createInvoice({ amountUsd: 10, orderId: "o", redirectURL: "https://app.example.com/success" });
|
|
54
|
+
|
|
55
|
+
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
|
56
|
+
expect(body.checkout.redirectURL).toBe("https://app.example.com/success");
|
|
57
|
+
|
|
58
|
+
fetchSpy.mockRestore();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("createInvoice throws on non-ok response", async () => {
|
|
62
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Unauthorized", { status: 401 }));
|
|
63
|
+
|
|
64
|
+
const client = new BTCPayClient({ apiKey: "bad-key", baseUrl: "https://btcpay.example.com", storeId: "s" });
|
|
65
|
+
await expect(client.createInvoice({ amountUsd: 10, orderId: "o" })).rejects.toThrow(
|
|
66
|
+
"BTCPay createInvoice failed (401)",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
fetchSpy.mockRestore();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("getInvoice sends correct request", async () => {
|
|
73
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
74
|
+
new Response(JSON.stringify({ id: "inv-001", status: "Settled", amount: "25", currency: "USD" }), {
|
|
75
|
+
status: 200,
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const client = new BTCPayClient({ apiKey: "k", baseUrl: "https://btcpay.example.com", storeId: "store-abc" });
|
|
80
|
+
const result = await client.getInvoice("inv-001");
|
|
81
|
+
|
|
82
|
+
expect(result.status).toBe("Settled");
|
|
83
|
+
expect(fetchSpy.mock.calls[0][0]).toBe("https://btcpay.example.com/api/v1/stores/store-abc/invoices/inv-001");
|
|
84
|
+
|
|
85
|
+
fetchSpy.mockRestore();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("loadCryptoConfig", () => {
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
delete process.env.BTCPAY_API_KEY;
|
|
92
|
+
delete process.env.BTCPAY_BASE_URL;
|
|
93
|
+
delete process.env.BTCPAY_STORE_ID;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
vi.unstubAllEnvs();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns null when BTCPAY_API_KEY is missing", () => {
|
|
101
|
+
vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
|
|
102
|
+
vi.stubEnv("BTCPAY_STORE_ID", "store-1");
|
|
103
|
+
expect(loadCryptoConfig()).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("returns null when BTCPAY_BASE_URL is missing", () => {
|
|
107
|
+
vi.stubEnv("BTCPAY_API_KEY", "test-key");
|
|
108
|
+
vi.stubEnv("BTCPAY_STORE_ID", "store-1");
|
|
109
|
+
expect(loadCryptoConfig()).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns null when BTCPAY_STORE_ID is missing", () => {
|
|
113
|
+
vi.stubEnv("BTCPAY_API_KEY", "test-key");
|
|
114
|
+
vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
|
|
115
|
+
expect(loadCryptoConfig()).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns config when all env vars are set", () => {
|
|
119
|
+
vi.stubEnv("BTCPAY_API_KEY", "test-key");
|
|
120
|
+
vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
|
|
121
|
+
vi.stubEnv("BTCPAY_STORE_ID", "store-1");
|
|
122
|
+
expect(loadCryptoConfig()).toEqual({
|
|
123
|
+
apiKey: "test-key",
|
|
124
|
+
baseUrl: "https://btcpay.test",
|
|
125
|
+
storeId: "store-1",
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns null when all env vars are missing", () => {
|
|
130
|
+
expect(loadCryptoConfig()).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
});
|