@wopr-network/platform-core 1.47.0 → 1.49.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/billing/crypto/__tests__/unified-checkout.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/unified-checkout.test.js +63 -0
- package/dist/billing/crypto/__tests__/watcher-service.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/watcher-service.test.js +174 -0
- package/dist/billing/crypto/__tests__/webhook-confirmations.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/webhook-confirmations.test.js +304 -0
- package/dist/billing/crypto/btc/__tests__/settler.test.js +1 -0
- package/dist/billing/crypto/btc/__tests__/watcher.test.d.ts +1 -0
- package/dist/billing/crypto/btc/__tests__/watcher.test.js +170 -0
- package/dist/billing/crypto/btc/types.d.ts +3 -1
- package/dist/billing/crypto/btc/watcher.d.ts +6 -1
- package/dist/billing/crypto/btc/watcher.js +20 -6
- package/dist/billing/crypto/charge-store.d.ts +27 -2
- package/dist/billing/crypto/charge-store.js +67 -1
- package/dist/billing/crypto/charge-store.test.js +180 -1
- package/dist/billing/crypto/client.d.ts +2 -0
- package/dist/billing/crypto/cursor-store.d.ts +10 -3
- package/dist/billing/crypto/cursor-store.js +21 -1
- package/dist/billing/crypto/evm/__tests__/eth-settler.test.js +2 -0
- package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +31 -4
- package/dist/billing/crypto/evm/__tests__/settler.test.js +2 -0
- package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.d.ts +1 -0
- package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +144 -0
- package/dist/billing/crypto/evm/__tests__/watcher.test.js +6 -2
- package/dist/billing/crypto/evm/eth-watcher.d.ts +11 -8
- package/dist/billing/crypto/evm/eth-watcher.js +27 -13
- package/dist/billing/crypto/evm/types.d.ts +5 -1
- package/dist/billing/crypto/evm/watcher.d.ts +9 -1
- package/dist/billing/crypto/evm/watcher.js +36 -13
- package/dist/billing/crypto/index.d.ts +3 -3
- package/dist/billing/crypto/index.js +1 -1
- package/dist/billing/crypto/key-server-webhook.d.ts +17 -4
- package/dist/billing/crypto/key-server-webhook.js +76 -15
- package/dist/billing/crypto/types.d.ts +16 -0
- package/dist/billing/crypto/unified-checkout.d.ts +8 -17
- package/dist/billing/crypto/unified-checkout.js +17 -131
- package/dist/billing/crypto/watcher-service.d.ts +22 -2
- package/dist/billing/crypto/watcher-service.js +71 -30
- package/dist/billing/payment-processor.d.ts +2 -0
- package/dist/billing/payment-processor.test.js +1 -0
- package/dist/billing/stripe/stripe-payment-processor.d.ts +1 -0
- package/dist/billing/stripe/stripe-payment-processor.js +27 -4
- package/dist/billing/stripe/stripe-payment-processor.test.js +95 -1
- package/dist/db/schema/crypto.d.ts +68 -0
- package/dist/db/schema/crypto.js +8 -0
- package/dist/monetization/crypto/__tests__/webhook.test.js +2 -1
- package/dist/monetization/stripe/stripe-payment-processor.d.ts +1 -0
- package/dist/monetization/stripe/stripe-payment-processor.js +25 -3
- package/dist/monetization/stripe/stripe-payment-processor.test.js +73 -1
- package/dist/trpc/org-remove-payment-method-router.test.js +1 -0
- package/drizzle/migrations/0016_charge_progress_columns.sql +4 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/billing/crypto/__tests__/unified-checkout.test.ts +83 -0
- package/src/billing/crypto/__tests__/watcher-service.test.ts +242 -0
- package/src/billing/crypto/__tests__/webhook-confirmations.test.ts +367 -0
- package/src/billing/crypto/btc/__tests__/settler.test.ts +1 -0
- package/src/billing/crypto/btc/__tests__/watcher.test.ts +201 -0
- package/src/billing/crypto/btc/types.ts +3 -1
- package/src/billing/crypto/btc/watcher.ts +22 -6
- package/src/billing/crypto/charge-store.test.ts +204 -1
- package/src/billing/crypto/charge-store.ts +86 -2
- package/src/billing/crypto/client.ts +2 -0
- package/src/billing/crypto/cursor-store.ts +31 -3
- package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +2 -0
- package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +31 -4
- package/src/billing/crypto/evm/__tests__/settler.test.ts +2 -0
- package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +176 -0
- package/src/billing/crypto/evm/__tests__/watcher.test.ts +6 -2
- package/src/billing/crypto/evm/eth-watcher.ts +34 -14
- package/src/billing/crypto/evm/types.ts +5 -1
- package/src/billing/crypto/evm/watcher.ts +39 -13
- package/src/billing/crypto/index.ts +12 -3
- package/src/billing/crypto/key-server-webhook.ts +92 -21
- package/src/billing/crypto/types.ts +18 -0
- package/src/billing/crypto/unified-checkout.ts +20 -179
- package/src/billing/crypto/watcher-service.ts +85 -32
- package/src/billing/payment-processor.test.ts +1 -0
- package/src/billing/payment-processor.ts +3 -0
- package/src/billing/stripe/stripe-payment-processor.test.ts +113 -1
- package/src/billing/stripe/stripe-payment-processor.ts +33 -5
- package/src/db/schema/crypto.ts +8 -0
- package/src/monetization/crypto/__tests__/webhook.test.ts +2 -1
- package/src/monetization/stripe/stripe-payment-processor.test.ts +89 -1
- package/src/monetization/stripe/stripe-payment-processor.ts +31 -4
- package/src/trpc/org-remove-payment-method-router.test.ts +1 -0
|
@@ -96,7 +96,7 @@ describe("StripePaymentProcessor", () => {
|
|
|
96
96
|
const result = await processor.listPaymentMethods("tenant-1");
|
|
97
97
|
expect(result).toEqual([]);
|
|
98
98
|
});
|
|
99
|
-
it("returns formatted payment methods with card label", async () => {
|
|
99
|
+
it("returns formatted payment methods with card label and reads actual Stripe default", async () => {
|
|
100
100
|
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
101
101
|
vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
|
|
102
102
|
data: [
|
|
@@ -104,17 +104,55 @@ describe("StripePaymentProcessor", () => {
|
|
|
104
104
|
{ id: "pm_2", card: { brand: "mastercard", last4: "5555" } },
|
|
105
105
|
],
|
|
106
106
|
});
|
|
107
|
+
vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
|
|
108
|
+
deleted: false,
|
|
109
|
+
invoice_settings: { default_payment_method: "pm_1" },
|
|
110
|
+
});
|
|
107
111
|
const result = await processor.listPaymentMethods("tenant-1");
|
|
108
112
|
expect(result).toEqual([
|
|
109
113
|
{ id: "pm_1", label: "Visa ending 4242", isDefault: true },
|
|
110
114
|
{ id: "pm_2", label: "Mastercard ending 5555", isDefault: false },
|
|
111
115
|
]);
|
|
112
116
|
});
|
|
117
|
+
it("marks second PM as default when Stripe says so", async () => {
|
|
118
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
119
|
+
vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
|
|
120
|
+
data: [
|
|
121
|
+
{ id: "pm_1", card: { brand: "visa", last4: "4242" } },
|
|
122
|
+
{ id: "pm_2", card: { brand: "mastercard", last4: "5555" } },
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
|
|
126
|
+
deleted: false,
|
|
127
|
+
invoice_settings: { default_payment_method: "pm_2" },
|
|
128
|
+
});
|
|
129
|
+
const result = await processor.listPaymentMethods("tenant-1");
|
|
130
|
+
expect(result).toEqual([
|
|
131
|
+
{ id: "pm_1", label: "Visa ending 4242", isDefault: false },
|
|
132
|
+
{ id: "pm_2", label: "Mastercard ending 5555", isDefault: true },
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
it("marks no PM as default when customer has no default set", async () => {
|
|
136
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
137
|
+
vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
|
|
138
|
+
data: [{ id: "pm_1", card: { brand: "visa", last4: "4242" } }],
|
|
139
|
+
});
|
|
140
|
+
vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
|
|
141
|
+
deleted: false,
|
|
142
|
+
invoice_settings: { default_payment_method: null },
|
|
143
|
+
});
|
|
144
|
+
const result = await processor.listPaymentMethods("tenant-1");
|
|
145
|
+
expect(result).toEqual([{ id: "pm_1", label: "Visa ending 4242", isDefault: false }]);
|
|
146
|
+
});
|
|
113
147
|
it("uses generic label for non-card payment methods", async () => {
|
|
114
148
|
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
115
149
|
vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
|
|
116
150
|
data: [{ id: "pm_bank", card: undefined }],
|
|
117
151
|
});
|
|
152
|
+
vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
|
|
153
|
+
deleted: false,
|
|
154
|
+
invoice_settings: { default_payment_method: "pm_bank" },
|
|
155
|
+
});
|
|
118
156
|
const result = await processor.listPaymentMethods("tenant-1");
|
|
119
157
|
expect(result).toEqual([{ id: "pm_bank", label: "Payment method pm_bank", isDefault: true }]);
|
|
120
158
|
});
|
|
@@ -152,6 +190,62 @@ describe("StripePaymentProcessor", () => {
|
|
|
152
190
|
expect(mocks.stripe.paymentMethods.detach).toHaveBeenCalledWith("pm_1");
|
|
153
191
|
});
|
|
154
192
|
});
|
|
193
|
+
// --- setDefaultPaymentMethod ---
|
|
194
|
+
describe("setDefaultPaymentMethod", () => {
|
|
195
|
+
it("throws when tenant has no Stripe customer", async () => {
|
|
196
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(null);
|
|
197
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow("No Stripe customer found for tenant: tenant-1");
|
|
198
|
+
});
|
|
199
|
+
it("throws PaymentMethodOwnershipError when PM belongs to different customer", async () => {
|
|
200
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
201
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
202
|
+
id: "pm_1",
|
|
203
|
+
customer: "cus_OTHER",
|
|
204
|
+
});
|
|
205
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow(PaymentMethodOwnershipError);
|
|
206
|
+
});
|
|
207
|
+
it("throws PaymentMethodOwnershipError when PM has no customer", async () => {
|
|
208
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
209
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
210
|
+
id: "pm_1",
|
|
211
|
+
customer: null,
|
|
212
|
+
});
|
|
213
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow(PaymentMethodOwnershipError);
|
|
214
|
+
});
|
|
215
|
+
it("calls stripe.customers.update with invoice_settings when ownership matches", async () => {
|
|
216
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
217
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
218
|
+
id: "pm_1",
|
|
219
|
+
customer: "cus_123",
|
|
220
|
+
});
|
|
221
|
+
vi.mocked(mocks.stripe.customers.update).mockResolvedValue({});
|
|
222
|
+
await processor.setDefaultPaymentMethod("tenant-1", "pm_1");
|
|
223
|
+
expect(mocks.stripe.customers.update).toHaveBeenCalledWith("cus_123", {
|
|
224
|
+
invoice_settings: { default_payment_method: "pm_1" },
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
it("succeeds when PM customer is an expanded Customer object", async () => {
|
|
228
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
229
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
230
|
+
id: "pm_1",
|
|
231
|
+
customer: { id: "cus_123", object: "customer" },
|
|
232
|
+
});
|
|
233
|
+
vi.mocked(mocks.stripe.customers.update).mockResolvedValue({});
|
|
234
|
+
await processor.setDefaultPaymentMethod("tenant-1", "pm_1");
|
|
235
|
+
expect(mocks.stripe.customers.update).toHaveBeenCalledWith("cus_123", {
|
|
236
|
+
invoice_settings: { default_payment_method: "pm_1" },
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
it("propagates Stripe API errors", async () => {
|
|
240
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
241
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
242
|
+
id: "pm_1",
|
|
243
|
+
customer: "cus_123",
|
|
244
|
+
});
|
|
245
|
+
vi.mocked(mocks.stripe.customers.update).mockRejectedValue(new Error("Stripe API error"));
|
|
246
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow("Stripe API error");
|
|
247
|
+
});
|
|
248
|
+
});
|
|
155
249
|
// --- getCustomerEmail ---
|
|
156
250
|
describe("getCustomerEmail", () => {
|
|
157
251
|
it("returns empty string when tenant has no Stripe customer", async () => {
|
|
@@ -282,6 +282,74 @@ export declare const cryptoCharges: import("drizzle-orm/pg-core").PgTableWithCol
|
|
|
282
282
|
identity: undefined;
|
|
283
283
|
generated: undefined;
|
|
284
284
|
}, {}, {}>;
|
|
285
|
+
confirmations: import("drizzle-orm/pg-core").PgColumn<{
|
|
286
|
+
name: "confirmations";
|
|
287
|
+
tableName: "crypto_charges";
|
|
288
|
+
dataType: "number";
|
|
289
|
+
columnType: "PgInteger";
|
|
290
|
+
data: number;
|
|
291
|
+
driverParam: string | number;
|
|
292
|
+
notNull: true;
|
|
293
|
+
hasDefault: true;
|
|
294
|
+
isPrimaryKey: false;
|
|
295
|
+
isAutoincrement: false;
|
|
296
|
+
hasRuntimeDefault: false;
|
|
297
|
+
enumValues: undefined;
|
|
298
|
+
baseColumn: never;
|
|
299
|
+
identity: undefined;
|
|
300
|
+
generated: undefined;
|
|
301
|
+
}, {}, {}>;
|
|
302
|
+
confirmationsRequired: import("drizzle-orm/pg-core").PgColumn<{
|
|
303
|
+
name: "confirmations_required";
|
|
304
|
+
tableName: "crypto_charges";
|
|
305
|
+
dataType: "number";
|
|
306
|
+
columnType: "PgInteger";
|
|
307
|
+
data: number;
|
|
308
|
+
driverParam: string | number;
|
|
309
|
+
notNull: true;
|
|
310
|
+
hasDefault: true;
|
|
311
|
+
isPrimaryKey: false;
|
|
312
|
+
isAutoincrement: false;
|
|
313
|
+
hasRuntimeDefault: false;
|
|
314
|
+
enumValues: undefined;
|
|
315
|
+
baseColumn: never;
|
|
316
|
+
identity: undefined;
|
|
317
|
+
generated: undefined;
|
|
318
|
+
}, {}, {}>;
|
|
319
|
+
txHash: import("drizzle-orm/pg-core").PgColumn<{
|
|
320
|
+
name: "tx_hash";
|
|
321
|
+
tableName: "crypto_charges";
|
|
322
|
+
dataType: "string";
|
|
323
|
+
columnType: "PgText";
|
|
324
|
+
data: string;
|
|
325
|
+
driverParam: string;
|
|
326
|
+
notNull: false;
|
|
327
|
+
hasDefault: false;
|
|
328
|
+
isPrimaryKey: false;
|
|
329
|
+
isAutoincrement: false;
|
|
330
|
+
hasRuntimeDefault: false;
|
|
331
|
+
enumValues: [string, ...string[]];
|
|
332
|
+
baseColumn: never;
|
|
333
|
+
identity: undefined;
|
|
334
|
+
generated: undefined;
|
|
335
|
+
}, {}, {}>;
|
|
336
|
+
amountReceivedCents: import("drizzle-orm/pg-core").PgColumn<{
|
|
337
|
+
name: "amount_received_cents";
|
|
338
|
+
tableName: "crypto_charges";
|
|
339
|
+
dataType: "number";
|
|
340
|
+
columnType: "PgInteger";
|
|
341
|
+
data: number;
|
|
342
|
+
driverParam: string | number;
|
|
343
|
+
notNull: true;
|
|
344
|
+
hasDefault: true;
|
|
345
|
+
isPrimaryKey: false;
|
|
346
|
+
isAutoincrement: false;
|
|
347
|
+
hasRuntimeDefault: false;
|
|
348
|
+
enumValues: undefined;
|
|
349
|
+
baseColumn: never;
|
|
350
|
+
identity: undefined;
|
|
351
|
+
generated: undefined;
|
|
352
|
+
}, {}, {}>;
|
|
285
353
|
};
|
|
286
354
|
dialect: "pg";
|
|
287
355
|
}>;
|
package/dist/db/schema/crypto.js
CHANGED
|
@@ -27,6 +27,14 @@ export const cryptoCharges = pgTable("crypto_charges", {
|
|
|
27
27
|
expectedAmount: text("expected_amount"),
|
|
28
28
|
/** Running total of received crypto in native units. Accumulates across partial payments. */
|
|
29
29
|
receivedAmount: text("received_amount"),
|
|
30
|
+
/** Number of blockchain confirmations observed so far. */
|
|
31
|
+
confirmations: integer("confirmations").notNull().default(0),
|
|
32
|
+
/** Required confirmations for settlement (copied from payment method at creation). */
|
|
33
|
+
confirmationsRequired: integer("confirmations_required").notNull().default(1),
|
|
34
|
+
/** Blockchain transaction hash for the payment. */
|
|
35
|
+
txHash: text("tx_hash"),
|
|
36
|
+
/** Amount received so far in USD cents (integer). Converted from crypto at time of receipt. */
|
|
37
|
+
amountReceivedCents: integer("amount_received_cents").notNull().default(0),
|
|
30
38
|
}, (table) => [
|
|
31
39
|
index("idx_crypto_charges_tenant").on(table.tenantId),
|
|
32
40
|
index("idx_crypto_charges_status").on(table.status),
|
|
@@ -120,7 +120,8 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
120
120
|
it("updates charge status on every webhook call", async () => {
|
|
121
121
|
await handleCryptoWebhook(deps, makePayload({ status: "partial" }));
|
|
122
122
|
const charge = await chargeStore.getByReferenceId("chg-test-001");
|
|
123
|
-
|
|
123
|
+
// DB stores legacy status values; "partial" maps to "Processing" internally
|
|
124
|
+
expect(charge?.status).toBe("Processing");
|
|
124
125
|
});
|
|
125
126
|
it("settles charge when status is confirmed", async () => {
|
|
126
127
|
await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
@@ -33,6 +33,7 @@ export declare class StripePaymentProcessor implements IPaymentProcessor {
|
|
|
33
33
|
}>;
|
|
34
34
|
setupPaymentMethod(tenant: string): Promise<SetupResult>;
|
|
35
35
|
listPaymentMethods(tenant: string): Promise<SavedPaymentMethod[]>;
|
|
36
|
+
setDefaultPaymentMethod(tenant: string, paymentMethodId: string): Promise<void>;
|
|
36
37
|
detachPaymentMethod(tenant: string, paymentMethodId: string): Promise<void>;
|
|
37
38
|
getCustomerEmail(tenantId: string): Promise<string>;
|
|
38
39
|
updateCustomerEmail(tenantId: string, email: string): Promise<void>;
|
|
@@ -89,13 +89,35 @@ export class StripePaymentProcessor {
|
|
|
89
89
|
if (!mapping) {
|
|
90
90
|
return [];
|
|
91
91
|
}
|
|
92
|
-
const methods = await
|
|
93
|
-
|
|
92
|
+
const [methods, customer] = await Promise.all([
|
|
93
|
+
this.stripe.customers.listPaymentMethods(mapping.processor_customer_id),
|
|
94
|
+
this.stripe.customers.retrieve(mapping.processor_customer_id),
|
|
95
|
+
]);
|
|
96
|
+
const defaultPmId = !customer.deleted && customer.invoice_settings?.default_payment_method
|
|
97
|
+
? typeof customer.invoice_settings.default_payment_method === "string"
|
|
98
|
+
? customer.invoice_settings.default_payment_method
|
|
99
|
+
: customer.invoice_settings.default_payment_method.id
|
|
100
|
+
: null;
|
|
101
|
+
return methods.data.map((pm) => ({
|
|
94
102
|
id: pm.id,
|
|
95
103
|
label: formatPaymentMethodLabel(pm),
|
|
96
|
-
isDefault:
|
|
104
|
+
isDefault: defaultPmId ? pm.id === defaultPmId : false,
|
|
97
105
|
}));
|
|
98
106
|
}
|
|
107
|
+
async setDefaultPaymentMethod(tenant, paymentMethodId) {
|
|
108
|
+
const mapping = await this.tenantRepo.getByTenant(tenant);
|
|
109
|
+
if (!mapping) {
|
|
110
|
+
throw new Error(`No Stripe customer found for tenant: ${tenant}`);
|
|
111
|
+
}
|
|
112
|
+
const pm = await this.stripe.paymentMethods.retrieve(paymentMethodId);
|
|
113
|
+
const pmCustomerId = typeof pm.customer === "string" ? pm.customer : (pm.customer?.id ?? null);
|
|
114
|
+
if (!pmCustomerId || pmCustomerId !== mapping.processor_customer_id) {
|
|
115
|
+
throw new PaymentMethodOwnershipError();
|
|
116
|
+
}
|
|
117
|
+
await this.stripe.customers.update(mapping.processor_customer_id, {
|
|
118
|
+
invoice_settings: { default_payment_method: paymentMethodId },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
99
121
|
async detachPaymentMethod(tenant, paymentMethodId) {
|
|
100
122
|
const mapping = await this.tenantRepo.getByTenant(tenant);
|
|
101
123
|
if (!mapping) {
|
|
@@ -102,7 +102,7 @@ describe("StripePaymentProcessor", () => {
|
|
|
102
102
|
const result = await processor.listPaymentMethods("tenant-1");
|
|
103
103
|
expect(result).toEqual([]);
|
|
104
104
|
});
|
|
105
|
-
it("returns formatted payment methods with card label", async () => {
|
|
105
|
+
it("returns formatted payment methods with card label and reads actual Stripe default", async () => {
|
|
106
106
|
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
107
107
|
vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
|
|
108
108
|
data: [
|
|
@@ -110,6 +110,10 @@ describe("StripePaymentProcessor", () => {
|
|
|
110
110
|
{ id: "pm_2", card: { brand: "mastercard", last4: "5555" } },
|
|
111
111
|
],
|
|
112
112
|
});
|
|
113
|
+
vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
|
|
114
|
+
deleted: false,
|
|
115
|
+
invoice_settings: { default_payment_method: "pm_1" },
|
|
116
|
+
});
|
|
113
117
|
const result = await processor.listPaymentMethods("tenant-1");
|
|
114
118
|
expect(result).toEqual([
|
|
115
119
|
{ id: "pm_1", label: "Visa ending 4242", isDefault: true },
|
|
@@ -121,6 +125,10 @@ describe("StripePaymentProcessor", () => {
|
|
|
121
125
|
vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
|
|
122
126
|
data: [{ id: "pm_bank", card: undefined }],
|
|
123
127
|
});
|
|
128
|
+
vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
|
|
129
|
+
deleted: false,
|
|
130
|
+
invoice_settings: { default_payment_method: "pm_bank" },
|
|
131
|
+
});
|
|
124
132
|
const result = await processor.listPaymentMethods("tenant-1");
|
|
125
133
|
expect(result).toEqual([{ id: "pm_bank", label: "Payment method pm_bank", isDefault: true }]);
|
|
126
134
|
});
|
|
@@ -158,6 +166,70 @@ describe("StripePaymentProcessor", () => {
|
|
|
158
166
|
expect(mocks.stripe.paymentMethods.detach).toHaveBeenCalledWith("pm_1");
|
|
159
167
|
});
|
|
160
168
|
});
|
|
169
|
+
// --- setDefaultPaymentMethod ---
|
|
170
|
+
describe("setDefaultPaymentMethod", () => {
|
|
171
|
+
it("throws when tenant has no Stripe customer", async () => {
|
|
172
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(null);
|
|
173
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow("No Stripe customer found for tenant: tenant-1");
|
|
174
|
+
});
|
|
175
|
+
it("throws PaymentMethodOwnershipError when PM has no customer", async () => {
|
|
176
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
177
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
178
|
+
id: "pm_1",
|
|
179
|
+
customer: null,
|
|
180
|
+
});
|
|
181
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow(PaymentMethodOwnershipError);
|
|
182
|
+
});
|
|
183
|
+
it("throws PaymentMethodOwnershipError when PM belongs to different customer", async () => {
|
|
184
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
185
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
186
|
+
id: "pm_1",
|
|
187
|
+
customer: "cus_OTHER",
|
|
188
|
+
});
|
|
189
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow(PaymentMethodOwnershipError);
|
|
190
|
+
});
|
|
191
|
+
it("throws PaymentMethodOwnershipError when PM customer is an expanded object with different id", async () => {
|
|
192
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
193
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
194
|
+
id: "pm_1",
|
|
195
|
+
customer: { id: "cus_OTHER", object: "customer" },
|
|
196
|
+
});
|
|
197
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow(PaymentMethodOwnershipError);
|
|
198
|
+
});
|
|
199
|
+
it("sets default when ownership matches (string customer)", async () => {
|
|
200
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
201
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
202
|
+
id: "pm_1",
|
|
203
|
+
customer: "cus_123",
|
|
204
|
+
});
|
|
205
|
+
vi.mocked(mocks.stripe.customers.update).mockResolvedValue({});
|
|
206
|
+
await processor.setDefaultPaymentMethod("tenant-1", "pm_1");
|
|
207
|
+
expect(mocks.stripe.customers.update).toHaveBeenCalledWith("cus_123", {
|
|
208
|
+
invoice_settings: { default_payment_method: "pm_1" },
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
it("sets default when ownership matches (expanded Customer object)", async () => {
|
|
212
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
213
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
214
|
+
id: "pm_1",
|
|
215
|
+
customer: { id: "cus_123", object: "customer" },
|
|
216
|
+
});
|
|
217
|
+
vi.mocked(mocks.stripe.customers.update).mockResolvedValue({});
|
|
218
|
+
await processor.setDefaultPaymentMethod("tenant-1", "pm_1");
|
|
219
|
+
expect(mocks.stripe.customers.update).toHaveBeenCalledWith("cus_123", {
|
|
220
|
+
invoice_settings: { default_payment_method: "pm_1" },
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
it("propagates Stripe errors", async () => {
|
|
224
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
225
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
226
|
+
id: "pm_1",
|
|
227
|
+
customer: "cus_123",
|
|
228
|
+
});
|
|
229
|
+
vi.mocked(mocks.stripe.customers.update).mockRejectedValue(new Error("Stripe network error"));
|
|
230
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow("Stripe network error");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
161
233
|
// --- getCustomerEmail ---
|
|
162
234
|
describe("getCustomerEmail", () => {
|
|
163
235
|
it("returns empty string when tenant has no Stripe customer", async () => {
|
|
@@ -38,6 +38,7 @@ function makeMockProcessor(methods) {
|
|
|
38
38
|
getCustomerEmail: vi.fn().mockResolvedValue(""),
|
|
39
39
|
updateCustomerEmail: vi.fn(),
|
|
40
40
|
listInvoices: vi.fn().mockResolvedValue([]),
|
|
41
|
+
setDefaultPaymentMethod: vi.fn().mockResolvedValue(undefined),
|
|
41
42
|
};
|
|
42
43
|
}
|
|
43
44
|
function makeMockAutoTopupSettings(overrides) {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
ALTER TABLE "crypto_charges" ADD COLUMN "confirmations" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
|
2
|
+
ALTER TABLE "crypto_charges" ADD COLUMN "confirmations_required" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
|
3
|
+
ALTER TABLE "crypto_charges" ADD COLUMN "tx_hash" text;--> statement-breakpoint
|
|
4
|
+
ALTER TABLE "crypto_charges" ADD COLUMN "amount_received_cents" integer DEFAULT 0 NOT NULL;
|
|
@@ -113,6 +113,13 @@
|
|
|
113
113
|
"when": 1742918400000,
|
|
114
114
|
"tag": "0015_callback_url",
|
|
115
115
|
"breakpoints": true
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"idx": 16,
|
|
119
|
+
"version": "7",
|
|
120
|
+
"when": 1743004800000,
|
|
121
|
+
"tag": "0016_charge_progress_columns",
|
|
122
|
+
"breakpoints": true
|
|
116
123
|
}
|
|
117
124
|
]
|
|
118
125
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { CryptoServiceClient } from "../client.js";
|
|
3
|
+
import { createUnifiedCheckout, MIN_CHECKOUT_USD } from "../unified-checkout.js";
|
|
4
|
+
|
|
5
|
+
function mockCryptoService(): CryptoServiceClient {
|
|
6
|
+
return {
|
|
7
|
+
createCharge: vi.fn().mockResolvedValue({
|
|
8
|
+
chargeId: "btc:bc1qtest",
|
|
9
|
+
address: "bc1qtest",
|
|
10
|
+
chain: "bitcoin",
|
|
11
|
+
token: "BTC",
|
|
12
|
+
amountUsd: 50,
|
|
13
|
+
displayAmount: "0.00076923 BTC",
|
|
14
|
+
derivationIndex: 7,
|
|
15
|
+
expiresAt: "2026-03-21T23:00:00Z",
|
|
16
|
+
}),
|
|
17
|
+
listChains: vi.fn(),
|
|
18
|
+
deriveAddress: vi.fn(),
|
|
19
|
+
getCharge: vi.fn(),
|
|
20
|
+
} as unknown as CryptoServiceClient;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("createUnifiedCheckout", () => {
|
|
24
|
+
it("delegates to CryptoServiceClient.createCharge", async () => {
|
|
25
|
+
const service = mockCryptoService();
|
|
26
|
+
const result = await createUnifiedCheckout({ cryptoService: service }, "btc", { tenant: "t-1", amountUsd: 50 });
|
|
27
|
+
|
|
28
|
+
expect(result.depositAddress).toBe("bc1qtest");
|
|
29
|
+
expect(result.displayAmount).toBe("0.00076923 BTC");
|
|
30
|
+
expect(result.amountUsd).toBe(50);
|
|
31
|
+
expect(result.token).toBe("BTC");
|
|
32
|
+
expect(result.chain).toBe("bitcoin");
|
|
33
|
+
expect(result.referenceId).toBe("btc:bc1qtest");
|
|
34
|
+
|
|
35
|
+
expect(service.createCharge).toHaveBeenCalledWith({
|
|
36
|
+
chain: "btc",
|
|
37
|
+
amountUsd: 50,
|
|
38
|
+
callbackUrl: undefined,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("passes callbackUrl to createCharge", async () => {
|
|
43
|
+
const service = mockCryptoService();
|
|
44
|
+
await createUnifiedCheckout({ cryptoService: service }, "base-usdc", {
|
|
45
|
+
tenant: "t-1",
|
|
46
|
+
amountUsd: 25,
|
|
47
|
+
callbackUrl: "https://example.com/hook",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(service.createCharge).toHaveBeenCalledWith({
|
|
51
|
+
chain: "base-usdc",
|
|
52
|
+
amountUsd: 25,
|
|
53
|
+
callbackUrl: "https://example.com/hook",
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("rejects amount below minimum", async () => {
|
|
58
|
+
const service = mockCryptoService();
|
|
59
|
+
await expect(
|
|
60
|
+
createUnifiedCheckout({ cryptoService: service }, "btc", { tenant: "t-1", amountUsd: 5 }),
|
|
61
|
+
).rejects.toThrow(`Minimum payment amount is $${MIN_CHECKOUT_USD}`);
|
|
62
|
+
|
|
63
|
+
expect(service.createCharge).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("rejects non-finite amount", async () => {
|
|
67
|
+
const service = mockCryptoService();
|
|
68
|
+
await expect(
|
|
69
|
+
createUnifiedCheckout({ cryptoService: service }, "btc", { tenant: "t-1", amountUsd: NaN }),
|
|
70
|
+
).rejects.toThrow(`Minimum payment amount is $${MIN_CHECKOUT_USD}`);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("propagates createCharge errors", async () => {
|
|
74
|
+
const service = mockCryptoService();
|
|
75
|
+
(service.createCharge as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
76
|
+
new Error("CryptoService createCharge failed (500): Internal Server Error"),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
await expect(
|
|
80
|
+
createUnifiedCheckout({ cryptoService: service }, "btc", { tenant: "t-1", amountUsd: 50 }),
|
|
81
|
+
).rejects.toThrow("CryptoService createCharge failed (500)");
|
|
82
|
+
});
|
|
83
|
+
});
|