@stardeck-customer-apps/testing 0.28.0 → 0.30.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/SKILL.md +50 -3
- package/dist/index.d.mts +86 -1
- package/dist/index.d.ts +86 -1
- package/dist/index.js +583 -4
- package/dist/index.mjs +583 -4
- package/dist/next/headers-shim.js +6 -0
- package/dist/next/headers-shim.mjs +6 -0
- package/dist/setup.js +379 -2
- package/dist/setup.mjs +379 -2
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -321,6 +321,12 @@ var state = globalSingleton("state", () => ({
|
|
|
321
321
|
stripePaymentIntents: /* @__PURE__ */ new Map(),
|
|
322
322
|
stripePaymentIntentCounter: 0,
|
|
323
323
|
stripeIdempotentIntents: /* @__PURE__ */ new Map(),
|
|
324
|
+
stripeRefunds: /* @__PURE__ */ new Map(),
|
|
325
|
+
stripeRefundCounter: 0,
|
|
326
|
+
stripeIdempotentRefunds: /* @__PURE__ */ new Map(),
|
|
327
|
+
beamRefunds: /* @__PURE__ */ new Map(),
|
|
328
|
+
beamRefundCounter: 0,
|
|
329
|
+
beamIdempotentRefunds: /* @__PURE__ */ new Map(),
|
|
324
330
|
stripeAllowedMethods: ["card", "promptpay"],
|
|
325
331
|
stripeChargesEnabled: true,
|
|
326
332
|
uploads: [],
|
|
@@ -2095,7 +2101,11 @@ function decodeNextRedirect(error) {
|
|
|
2095
2101
|
var SIM_STRIPE_PUBLISHABLE_KEY = "pk_test_sim_stripe";
|
|
2096
2102
|
var SIM_STRIPE_DEFAULT_ACCOUNT = "acct_sim_default";
|
|
2097
2103
|
var METHODS = ["card", "promptpay"];
|
|
2098
|
-
var RESERVED_METADATA_KEYS = [
|
|
2104
|
+
var RESERVED_METADATA_KEYS = [
|
|
2105
|
+
"projectId",
|
|
2106
|
+
"stardeckReferenceId",
|
|
2107
|
+
"stardeckStagingDeploymentId"
|
|
2108
|
+
];
|
|
2099
2109
|
var PROMPTPAY_QR_TTL_SECONDS = 15 * 60;
|
|
2100
2110
|
var PATH_RE = /^\/api\/store\/(?!beam\/)([^/]+)\/(payment-intents(?:\/([^/]+)(?:\/(cancel))?)?|elements-config|readiness)$/;
|
|
2101
2111
|
function err(error, status, code) {
|
|
@@ -2296,6 +2306,36 @@ function createIntent(body, account, projectId) {
|
|
|
2296
2306
|
);
|
|
2297
2307
|
return json({ paymentIntent: toStripePaymentIntentSummary(intent) }, 201);
|
|
2298
2308
|
}
|
|
2309
|
+
function createHostedPaymentIntent(input) {
|
|
2310
|
+
state.stripePaymentIntentCounter += 1;
|
|
2311
|
+
const id = `pi_sim_${state.stripePaymentIntentCounter}`;
|
|
2312
|
+
const metadata = appMetadata(input.metadata);
|
|
2313
|
+
const intent = {
|
|
2314
|
+
id,
|
|
2315
|
+
projectId: input.projectId,
|
|
2316
|
+
account: "default",
|
|
2317
|
+
connectedAccountId: resolveAccount("default"),
|
|
2318
|
+
playground: false,
|
|
2319
|
+
status: "succeeded",
|
|
2320
|
+
amount: input.amount,
|
|
2321
|
+
currency: input.currency.toLowerCase(),
|
|
2322
|
+
method: "card",
|
|
2323
|
+
referenceId: input.sessionId,
|
|
2324
|
+
appMetadata: metadata,
|
|
2325
|
+
metadata: { ...metadata, projectId: input.projectId },
|
|
2326
|
+
description: null,
|
|
2327
|
+
receiptEmail: null,
|
|
2328
|
+
returnUrl: null,
|
|
2329
|
+
clientSecret: `${id}_secret_sim`,
|
|
2330
|
+
qr: null,
|
|
2331
|
+
latestChargeId: `ch_sim_${id}`,
|
|
2332
|
+
amountRefunded: 0,
|
|
2333
|
+
lastPaymentError: null,
|
|
2334
|
+
created: Math.floor(Date.now() / 1e3)
|
|
2335
|
+
};
|
|
2336
|
+
state.stripePaymentIntents.set(id, intent);
|
|
2337
|
+
return intent;
|
|
2338
|
+
}
|
|
2299
2339
|
function ownedIntent(id, projectId) {
|
|
2300
2340
|
const intent = state.stripePaymentIntents.get(id);
|
|
2301
2341
|
return intent && intent.projectId === projectId ? intent : void 0;
|
|
@@ -2433,6 +2473,485 @@ function stripePaymentIntentEvent(id) {
|
|
|
2433
2473
|
};
|
|
2434
2474
|
}
|
|
2435
2475
|
|
|
2476
|
+
// src/simulator/refunds.ts
|
|
2477
|
+
var REFUND_ROW_KEY = "refundRowId";
|
|
2478
|
+
var REFUND_METADATA_MAX_KEYS = 50 - RESERVED_METADATA_KEYS.length - 1;
|
|
2479
|
+
var REFUND_REASONS = ["duplicate", "fraudulent", "requested_by_customer"];
|
|
2480
|
+
var PROMPTPAY_BANK_DETAILS_WINDOW_SECONDS = 7 * 24 * 60 * 60;
|
|
2481
|
+
var BEAM_PARTIAL_METHODS = ["CARD", "CARD_TOKEN"];
|
|
2482
|
+
function refundFailure(plan) {
|
|
2483
|
+
return err(plan.message, plan.status, plan.code);
|
|
2484
|
+
}
|
|
2485
|
+
function isPositiveInt(value) {
|
|
2486
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
2487
|
+
}
|
|
2488
|
+
function hasValidKey(value) {
|
|
2489
|
+
return typeof value === "string" && value.length >= 1 && value.length <= 255;
|
|
2490
|
+
}
|
|
2491
|
+
function isValidDeploymentId(value) {
|
|
2492
|
+
return typeof value === "string" && UUID_RE2.test(value);
|
|
2493
|
+
}
|
|
2494
|
+
function isValidRefundMetadata(value) {
|
|
2495
|
+
if (!isStringRecord(value)) return false;
|
|
2496
|
+
const entries = Object.entries(value);
|
|
2497
|
+
return entries.length <= REFUND_METADATA_MAX_KEYS && entries.every(
|
|
2498
|
+
([k, v]) => k.length >= 1 && k.length <= 40 && v.length <= 500 && !RESERVED_METADATA_KEYS.includes(k) && k !== REFUND_ROW_KEY
|
|
2499
|
+
);
|
|
2500
|
+
}
|
|
2501
|
+
var COUNTED_STRIPE_STATUSES = [
|
|
2502
|
+
"pending",
|
|
2503
|
+
"requires_action",
|
|
2504
|
+
"succeeded"
|
|
2505
|
+
];
|
|
2506
|
+
function refundedAmount(intentId) {
|
|
2507
|
+
let total = 0;
|
|
2508
|
+
for (const refund of state.stripeRefunds.values()) {
|
|
2509
|
+
if (refund.paymentIntentId === intentId && COUNTED_STRIPE_STATUSES.includes(refund.status)) {
|
|
2510
|
+
total += refund.amount;
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
return total;
|
|
2514
|
+
}
|
|
2515
|
+
function syncAmountRefunded(intent) {
|
|
2516
|
+
intent.amountRefunded = refundedAmount(intent.id);
|
|
2517
|
+
}
|
|
2518
|
+
function validateStripeRefund(body) {
|
|
2519
|
+
const checks = [
|
|
2520
|
+
[isValidDeploymentId(body.deploymentId), "Invalid deployment ID"],
|
|
2521
|
+
[hasValidKey(body.idempotencyKey), "idempotencyKey is required (1 to 255 characters)"],
|
|
2522
|
+
[isOptional(body.amount, isPositiveInt), "amount must be a positive integer in minor units"],
|
|
2523
|
+
[
|
|
2524
|
+
isOptional(body.reason, (v) => typeof v === "string" && REFUND_REASONS.includes(v)),
|
|
2525
|
+
`reason must be one of: ${REFUND_REASONS.join(", ")}`
|
|
2526
|
+
],
|
|
2527
|
+
[isOptional(body.instructionsEmail, isEmail), "instructionsEmail must be an email address"],
|
|
2528
|
+
[
|
|
2529
|
+
isOptional(
|
|
2530
|
+
body.refundRowId,
|
|
2531
|
+
(v) => typeof v === "string" && v.length >= 1 && v.length <= 500
|
|
2532
|
+
),
|
|
2533
|
+
"refundRowId must be 1 to 500 characters"
|
|
2534
|
+
],
|
|
2535
|
+
[
|
|
2536
|
+
isOptional(body.metadata, isValidRefundMetadata),
|
|
2537
|
+
`metadata must map keys (1-40 chars) to strings (max 500), at most ${REFUND_METADATA_MAX_KEYS} keys, without the keys the platform reserves (use refundRowId instead)`
|
|
2538
|
+
]
|
|
2539
|
+
];
|
|
2540
|
+
const failed = checks.find(([ok]) => !ok);
|
|
2541
|
+
return failed ? failed[1] : null;
|
|
2542
|
+
}
|
|
2543
|
+
function planStripeRefund(intent, requested) {
|
|
2544
|
+
if (intent.status !== "succeeded") {
|
|
2545
|
+
return {
|
|
2546
|
+
ok: false,
|
|
2547
|
+
status: 409,
|
|
2548
|
+
code: "payment_not_refundable",
|
|
2549
|
+
message: "This payment hasn't succeeded, so there is nothing to refund"
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
const remaining = intent.amount - refundedAmount(intent.id);
|
|
2553
|
+
const amount = requested ?? remaining;
|
|
2554
|
+
if (remaining <= 0 || amount > remaining) {
|
|
2555
|
+
return {
|
|
2556
|
+
ok: false,
|
|
2557
|
+
status: 409,
|
|
2558
|
+
code: "refund_exceeds_remaining",
|
|
2559
|
+
message: `Only ${remaining} can still be refunded on this payment`
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2562
|
+
return { ok: true, amount };
|
|
2563
|
+
}
|
|
2564
|
+
function toStripeRefundSummary(refund) {
|
|
2565
|
+
const intent = state.stripePaymentIntents.get(refund.paymentIntentId);
|
|
2566
|
+
return {
|
|
2567
|
+
id: refund.id,
|
|
2568
|
+
paymentIntentId: refund.paymentIntentId,
|
|
2569
|
+
amount: refund.amount,
|
|
2570
|
+
currency: refund.currency,
|
|
2571
|
+
status: refund.status,
|
|
2572
|
+
failureReason: refund.failureReason,
|
|
2573
|
+
pendingReason: refund.pendingReason,
|
|
2574
|
+
nextActionExpiresAt: refund.nextActionExpiresAt,
|
|
2575
|
+
refundRowId: refund.refundRowId,
|
|
2576
|
+
metadata: { ...refund.appMetadata },
|
|
2577
|
+
created: refund.created,
|
|
2578
|
+
livemode: false,
|
|
2579
|
+
playground: intent?.playground ?? false
|
|
2580
|
+
};
|
|
2581
|
+
}
|
|
2582
|
+
function newStripeRefund(input) {
|
|
2583
|
+
state.stripeRefundCounter += 1;
|
|
2584
|
+
const refund = {
|
|
2585
|
+
id: `re_sim_${state.stripeRefundCounter}`,
|
|
2586
|
+
paymentIntentId: input.intent.id,
|
|
2587
|
+
projectId: input.intent.projectId,
|
|
2588
|
+
amount: input.amount,
|
|
2589
|
+
currency: input.intent.currency,
|
|
2590
|
+
// A refund made in the Stripe dashboard is already done; the app's waits for `settleRefund`.
|
|
2591
|
+
status: input.origin === "dashboard" ? "succeeded" : "pending",
|
|
2592
|
+
failureReason: null,
|
|
2593
|
+
pendingReason: null,
|
|
2594
|
+
nextActionExpiresAt: null,
|
|
2595
|
+
refundRowId: input.refundRowId ?? null,
|
|
2596
|
+
appMetadata: input.appMetadata ?? {},
|
|
2597
|
+
reason: input.reason ?? null,
|
|
2598
|
+
origin: input.origin,
|
|
2599
|
+
revision: 0,
|
|
2600
|
+
created: Math.floor(Date.now() / 1e3)
|
|
2601
|
+
};
|
|
2602
|
+
state.stripeRefunds.set(refund.id, refund);
|
|
2603
|
+
syncAmountRefunded(input.intent);
|
|
2604
|
+
console.log(
|
|
2605
|
+
`[stardeck-testing] stripe refund ${refund.id} created payment=${refund.paymentIntentId} amount=${refund.amount} origin=${refund.origin}`
|
|
2606
|
+
);
|
|
2607
|
+
return refund;
|
|
2608
|
+
}
|
|
2609
|
+
function createStripeRefund(body, intent, projectId) {
|
|
2610
|
+
const scopedKey = JSON.stringify(["refund", projectId, intent.account, body.idempotencyKey]);
|
|
2611
|
+
const fingerprint = stableStringify({
|
|
2612
|
+
paymentIntentId: intent.id,
|
|
2613
|
+
amount: body.amount,
|
|
2614
|
+
reason: body.reason,
|
|
2615
|
+
instructionsEmail: body.instructionsEmail,
|
|
2616
|
+
refundRowId: body.refundRowId,
|
|
2617
|
+
metadata: body.metadata
|
|
2618
|
+
});
|
|
2619
|
+
const saved = state.stripeIdempotentRefunds.get(scopedKey);
|
|
2620
|
+
if (saved) {
|
|
2621
|
+
if (saved.fingerprint !== fingerprint) {
|
|
2622
|
+
return err(
|
|
2623
|
+
"This idempotency key was already used with different parameters. Use a new key for a different refund.",
|
|
2624
|
+
409,
|
|
2625
|
+
"idempotency_conflict"
|
|
2626
|
+
);
|
|
2627
|
+
}
|
|
2628
|
+
return json({ refund: toStripeRefundSummary(state.stripeRefunds.get(saved.id)) });
|
|
2629
|
+
}
|
|
2630
|
+
const plan = planStripeRefund(intent, body.amount);
|
|
2631
|
+
if (!plan.ok) return refundFailure(plan);
|
|
2632
|
+
const refund = newStripeRefund({
|
|
2633
|
+
intent,
|
|
2634
|
+
amount: plan.amount,
|
|
2635
|
+
origin: "app",
|
|
2636
|
+
refundRowId: typeof body.refundRowId === "string" ? body.refundRowId : null,
|
|
2637
|
+
appMetadata: appMetadata(body.metadata),
|
|
2638
|
+
reason: typeof body.reason === "string" ? body.reason : null
|
|
2639
|
+
});
|
|
2640
|
+
state.stripeIdempotentRefunds.set(scopedKey, { fingerprint, id: refund.id });
|
|
2641
|
+
return json({ refund: toStripeRefundSummary(refund) });
|
|
2642
|
+
}
|
|
2643
|
+
var STRIPE_REFUND_PATH = /^\/api\/store\/(?!beam\/)[^/]+\/(?:payment-intents\/([^/]+)\/refunds|refunds\/([^/]+))$/;
|
|
2644
|
+
async function handleStripeRefundRequest(request, url, projectId) {
|
|
2645
|
+
const match = url.pathname.match(STRIPE_REFUND_PATH);
|
|
2646
|
+
if (!match) return null;
|
|
2647
|
+
const [, intentId, refundId] = match;
|
|
2648
|
+
const isCreate = request.method === "POST" && intentId !== void 0;
|
|
2649
|
+
const isRead = request.method === "GET" && refundId !== void 0;
|
|
2650
|
+
if (!isCreate && !isRead) return null;
|
|
2651
|
+
if (!projectId) return err("Missing authentication", 401, "unauthorized");
|
|
2652
|
+
if (isRead) {
|
|
2653
|
+
if (!url.searchParams.get("deploymentId")) {
|
|
2654
|
+
return err("deploymentId is required", 400, "invalid_request");
|
|
2655
|
+
}
|
|
2656
|
+
const refund = state.stripeRefunds.get(decode(refundId));
|
|
2657
|
+
if (!refund || refund.projectId !== projectId) {
|
|
2658
|
+
return err("Refund not found", 404, "refund_not_found");
|
|
2659
|
+
}
|
|
2660
|
+
return json({ refund: toStripeRefundSummary(refund) });
|
|
2661
|
+
}
|
|
2662
|
+
const body = await readJsonBody(request);
|
|
2663
|
+
const problem = validateStripeRefund(body);
|
|
2664
|
+
if (problem) return err(problem, 400, "invalid_request");
|
|
2665
|
+
const intent = ownedIntent(decode(intentId), projectId);
|
|
2666
|
+
if (!intent) return err("No such payment for this app", 404, "payment_not_owned");
|
|
2667
|
+
return createStripeRefund(body, intent, projectId);
|
|
2668
|
+
}
|
|
2669
|
+
function requireStripeRefund(id) {
|
|
2670
|
+
const refund = state.stripeRefunds.get(id);
|
|
2671
|
+
if (!refund) throw new Error(`[stardeck-testing] Unknown Stripe refund id: ${id}`);
|
|
2672
|
+
return refund;
|
|
2673
|
+
}
|
|
2674
|
+
function settleStripeRefund(refund, outcome, failureReason) {
|
|
2675
|
+
const intent = state.stripePaymentIntents.get(refund.paymentIntentId);
|
|
2676
|
+
const settled = refund.status === "failed" || refund.status === "canceled";
|
|
2677
|
+
if (settled || outcome !== "failed" && refund.status === "succeeded") {
|
|
2678
|
+
throw new Error(
|
|
2679
|
+
`[stardeck-testing] Can't settle Stripe refund ${refund.id} as ${outcome}: it is ${refund.status}`
|
|
2680
|
+
);
|
|
2681
|
+
}
|
|
2682
|
+
if (outcome === "needs_customer") {
|
|
2683
|
+
if (intent.method !== "promptpay") {
|
|
2684
|
+
throw new Error(
|
|
2685
|
+
`[stardeck-testing] Stripe refund ${refund.id} is on a ${intent.method} payment; only PromptPay refunds wait for the customer's bank details`
|
|
2686
|
+
);
|
|
2687
|
+
}
|
|
2688
|
+
refund.status = "requires_action";
|
|
2689
|
+
refund.nextActionExpiresAt = refund.created + PROMPTPAY_BANK_DETAILS_WINDOW_SECONDS;
|
|
2690
|
+
} else {
|
|
2691
|
+
refund.status = outcome;
|
|
2692
|
+
refund.nextActionExpiresAt = null;
|
|
2693
|
+
refund.pendingReason = null;
|
|
2694
|
+
refund.failureReason = outcome === "failed" ? failureReason ?? "unknown" : null;
|
|
2695
|
+
}
|
|
2696
|
+
refund.revision += 1;
|
|
2697
|
+
syncAmountRefunded(intent);
|
|
2698
|
+
return refund;
|
|
2699
|
+
}
|
|
2700
|
+
function stripeRefundEvent(id, type) {
|
|
2701
|
+
const refund = requireStripeRefund(id);
|
|
2702
|
+
const intent = state.stripePaymentIntents.get(refund.paymentIntentId);
|
|
2703
|
+
let eventType = type;
|
|
2704
|
+
if (!eventType) {
|
|
2705
|
+
if (refund.status === "failed") eventType = "refund.failed";
|
|
2706
|
+
else eventType = refund.revision === 0 ? "refund.created" : "refund.updated";
|
|
2707
|
+
}
|
|
2708
|
+
const metadata = { ...refund.appMetadata };
|
|
2709
|
+
if (refund.origin === "app") metadata.projectId = refund.projectId;
|
|
2710
|
+
if (refund.refundRowId) metadata[REFUND_ROW_KEY] = refund.refundRowId;
|
|
2711
|
+
return {
|
|
2712
|
+
type: eventType,
|
|
2713
|
+
accountId: intent.connectedAccountId ?? "acct_stardeck_playground",
|
|
2714
|
+
data: {
|
|
2715
|
+
id: refund.id,
|
|
2716
|
+
object: "refund",
|
|
2717
|
+
amount: refund.amount,
|
|
2718
|
+
currency: refund.currency,
|
|
2719
|
+
payment_intent: refund.paymentIntentId,
|
|
2720
|
+
charge: intent.latestChargeId,
|
|
2721
|
+
status: refund.status,
|
|
2722
|
+
reason: refund.reason,
|
|
2723
|
+
failure_reason: refund.failureReason,
|
|
2724
|
+
pending_reason: refund.pendingReason,
|
|
2725
|
+
next_action: refund.status === "requires_action" ? {
|
|
2726
|
+
type: "display_details",
|
|
2727
|
+
display_details: { expires_at: refund.nextActionExpiresAt }
|
|
2728
|
+
} : null,
|
|
2729
|
+
metadata,
|
|
2730
|
+
created: refund.created,
|
|
2731
|
+
livemode: false
|
|
2732
|
+
}
|
|
2733
|
+
};
|
|
2734
|
+
}
|
|
2735
|
+
function refundsOfCharge(chargeId) {
|
|
2736
|
+
return [...state.beamRefunds.values()].filter((r) => r.chargeId === chargeId);
|
|
2737
|
+
}
|
|
2738
|
+
function planBeamRefund(charge, requested) {
|
|
2739
|
+
if (charge.status !== "SUCCEEDED") {
|
|
2740
|
+
return {
|
|
2741
|
+
ok: false,
|
|
2742
|
+
status: 409,
|
|
2743
|
+
code: "payment_not_refundable",
|
|
2744
|
+
message: "This charge hasn't succeeded, so there is nothing to refund"
|
|
2745
|
+
};
|
|
2746
|
+
}
|
|
2747
|
+
const refunds = refundsOfCharge(charge.id);
|
|
2748
|
+
if (refunds.some((r) => r.status === "PENDING")) {
|
|
2749
|
+
return {
|
|
2750
|
+
ok: false,
|
|
2751
|
+
status: 409,
|
|
2752
|
+
code: "refund_in_progress",
|
|
2753
|
+
message: "This charge already has a refund in progress. Wait for it to finish."
|
|
2754
|
+
};
|
|
2755
|
+
}
|
|
2756
|
+
const refunded = refunds.filter((r) => r.status === "SUCCEEDED").reduce((n, r) => n + r.amount, 0);
|
|
2757
|
+
const remaining = charge.amount - refunded;
|
|
2758
|
+
const amount = requested ?? remaining;
|
|
2759
|
+
if (remaining <= 0 || amount > remaining) {
|
|
2760
|
+
return {
|
|
2761
|
+
ok: false,
|
|
2762
|
+
status: 409,
|
|
2763
|
+
code: "refund_exceeds_remaining",
|
|
2764
|
+
message: `Only ${remaining} can still be refunded on this charge`
|
|
2765
|
+
};
|
|
2766
|
+
}
|
|
2767
|
+
if (amount < charge.amount && !BEAM_PARTIAL_METHODS.includes(charge.paymentMethodType)) {
|
|
2768
|
+
return {
|
|
2769
|
+
ok: false,
|
|
2770
|
+
status: 409,
|
|
2771
|
+
code: "refund_partial_not_allowed",
|
|
2772
|
+
message: `${charge.paymentMethodType} charges can only be refunded in full`
|
|
2773
|
+
};
|
|
2774
|
+
}
|
|
2775
|
+
return { ok: true, amount };
|
|
2776
|
+
}
|
|
2777
|
+
function toBeamRefundSummary(refund) {
|
|
2778
|
+
return {
|
|
2779
|
+
refundId: refund.refundId,
|
|
2780
|
+
chargeId: refund.chargeId,
|
|
2781
|
+
amount: refund.amount,
|
|
2782
|
+
currency: refund.currency,
|
|
2783
|
+
status: refund.status,
|
|
2784
|
+
failureCode: refund.failureCode,
|
|
2785
|
+
reason: refund.reason,
|
|
2786
|
+
createdAt: refund.createdAt
|
|
2787
|
+
};
|
|
2788
|
+
}
|
|
2789
|
+
function newBeamRefund(input) {
|
|
2790
|
+
state.beamRefundCounter += 1;
|
|
2791
|
+
const refund = {
|
|
2792
|
+
refundId: `rf_sim_${state.beamRefundCounter}`,
|
|
2793
|
+
chargeId: input.charge.id,
|
|
2794
|
+
amount: input.amount,
|
|
2795
|
+
currency: input.charge.currency,
|
|
2796
|
+
status: input.origin === "dashboard" ? "SUCCEEDED" : "PENDING",
|
|
2797
|
+
failureCode: null,
|
|
2798
|
+
reason: input.reason ?? null,
|
|
2799
|
+
origin: input.origin,
|
|
2800
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2801
|
+
};
|
|
2802
|
+
state.beamRefunds.set(refund.refundId, refund);
|
|
2803
|
+
console.log(
|
|
2804
|
+
`[stardeck-testing] beam refund ${refund.refundId} created charge=${refund.chargeId} amount=${refund.amount} origin=${refund.origin}`
|
|
2805
|
+
);
|
|
2806
|
+
return refund;
|
|
2807
|
+
}
|
|
2808
|
+
function validateBeamRefund(body) {
|
|
2809
|
+
const checks = [
|
|
2810
|
+
[isValidDeploymentId(body.deploymentId), "Invalid deployment ID"],
|
|
2811
|
+
[hasValidKey(body.idempotencyKey), "idempotencyKey is required (1 to 255 characters)"],
|
|
2812
|
+
[isOptional(body.amount, isPositiveInt), "amount must be a positive integer in satang"],
|
|
2813
|
+
[
|
|
2814
|
+
isOptional(body.reason, (v) => typeof v === "string" && v.length <= 500),
|
|
2815
|
+
"reason must be at most 500 characters"
|
|
2816
|
+
]
|
|
2817
|
+
];
|
|
2818
|
+
const failed = checks.find(([ok]) => !ok);
|
|
2819
|
+
return failed ? failed[1] : null;
|
|
2820
|
+
}
|
|
2821
|
+
function createBeamRefund(body, charge, merchant) {
|
|
2822
|
+
const scopedKey = JSON.stringify(["beam-refund", merchant, body.idempotencyKey]);
|
|
2823
|
+
const fingerprint = stableStringify({
|
|
2824
|
+
chargeId: charge.id,
|
|
2825
|
+
amount: body.amount,
|
|
2826
|
+
reason: body.reason
|
|
2827
|
+
});
|
|
2828
|
+
const saved = state.beamIdempotentRefunds.get(scopedKey);
|
|
2829
|
+
if (saved) {
|
|
2830
|
+
if (saved.fingerprint !== fingerprint) {
|
|
2831
|
+
return err(
|
|
2832
|
+
"This idempotency key was already used with different parameters. Use a new key for a different refund.",
|
|
2833
|
+
409,
|
|
2834
|
+
"idempotency_conflict"
|
|
2835
|
+
);
|
|
2836
|
+
}
|
|
2837
|
+
return json({ refund: toBeamRefundSummary(state.beamRefunds.get(saved.id)) });
|
|
2838
|
+
}
|
|
2839
|
+
const plan = planBeamRefund(charge, body.amount);
|
|
2840
|
+
if (!plan.ok) return refundFailure(plan);
|
|
2841
|
+
const refund = newBeamRefund({
|
|
2842
|
+
charge,
|
|
2843
|
+
amount: plan.amount,
|
|
2844
|
+
origin: "app",
|
|
2845
|
+
reason: typeof body.reason === "string" ? body.reason : null
|
|
2846
|
+
});
|
|
2847
|
+
state.beamIdempotentRefunds.set(scopedKey, { fingerprint, id: refund.refundId });
|
|
2848
|
+
return json({ refund: toBeamRefundSummary(refund) });
|
|
2849
|
+
}
|
|
2850
|
+
var BEAM_REFUND_PATH = /^\/api\/store\/beam\/([^/]+)\/(?:charges\/([^/]+)\/refunds|refunds\/([^/]+))$/;
|
|
2851
|
+
async function handleBeamRefundRequest(request, url) {
|
|
2852
|
+
const match = url.pathname.match(BEAM_REFUND_PATH);
|
|
2853
|
+
if (!match) return null;
|
|
2854
|
+
const [, merchant, chargeId, refundId] = match;
|
|
2855
|
+
const isCreate = request.method === "POST" && chargeId !== void 0;
|
|
2856
|
+
const isRead = request.method === "GET" && refundId !== void 0;
|
|
2857
|
+
if (!isCreate && !isRead) return null;
|
|
2858
|
+
if (isRead) {
|
|
2859
|
+
if (!url.searchParams.get("deploymentId")) {
|
|
2860
|
+
return err("deploymentId is required", 400, "invalid_request");
|
|
2861
|
+
}
|
|
2862
|
+
const refund = state.beamRefunds.get(decode(refundId));
|
|
2863
|
+
if (!refund) return err("Refund not found", 404, "refund_not_found");
|
|
2864
|
+
return json({ refund: toBeamRefundSummary(refund) });
|
|
2865
|
+
}
|
|
2866
|
+
const body = await readJsonBody(request);
|
|
2867
|
+
const problem = validateBeamRefund(body);
|
|
2868
|
+
if (problem) return err(problem, 400, "invalid_request");
|
|
2869
|
+
const charge = state.directCharges.get(decode(chargeId));
|
|
2870
|
+
if (!charge) return err("No such charge for this app", 404, "payment_not_owned");
|
|
2871
|
+
return createBeamRefund(body, charge, decode(merchant));
|
|
2872
|
+
}
|
|
2873
|
+
function requireBeamRefund(id) {
|
|
2874
|
+
const refund = state.beamRefunds.get(id);
|
|
2875
|
+
if (!refund) throw new Error(`[stardeck-testing] Unknown Beam refund id: ${id}`);
|
|
2876
|
+
return refund;
|
|
2877
|
+
}
|
|
2878
|
+
function settleBeamRefund(refund, outcome, failureCode) {
|
|
2879
|
+
if (outcome === "needs_customer") {
|
|
2880
|
+
throw new Error(
|
|
2881
|
+
`[stardeck-testing] Beam refund ${refund.refundId} can't need the customer: Beam refunds have no customer step (only Stripe PromptPay does)`
|
|
2882
|
+
);
|
|
2883
|
+
}
|
|
2884
|
+
if (refund.status !== "PENDING") {
|
|
2885
|
+
throw new Error(
|
|
2886
|
+
`[stardeck-testing] Can't settle Beam refund ${refund.refundId} as ${outcome}: it is ${refund.status}, and Beam refund statuses are final`
|
|
2887
|
+
);
|
|
2888
|
+
}
|
|
2889
|
+
refund.status = outcome === "succeeded" ? "SUCCEEDED" : "FAILED";
|
|
2890
|
+
refund.failureCode = outcome === "failed" ? failureCode ?? "REFUND_FAILED" : null;
|
|
2891
|
+
return refund;
|
|
2892
|
+
}
|
|
2893
|
+
function beamRefundEvent(id) {
|
|
2894
|
+
const refund = requireBeamRefund(id);
|
|
2895
|
+
if (refund.status === "PENDING") {
|
|
2896
|
+
throw new Error(
|
|
2897
|
+
`[stardeck-testing] Beam refund ${id} is PENDING and has no event yet; call settleRefund first`
|
|
2898
|
+
);
|
|
2899
|
+
}
|
|
2900
|
+
return {
|
|
2901
|
+
type: refund.status === "SUCCEEDED" ? "refund.succeeded" : "refund.failed",
|
|
2902
|
+
payload: {
|
|
2903
|
+
refundId: refund.refundId,
|
|
2904
|
+
chargeId: refund.chargeId,
|
|
2905
|
+
merchantId: SIM_BEAM_MERCHANT_ID,
|
|
2906
|
+
status: refund.status,
|
|
2907
|
+
amount: refund.amount,
|
|
2908
|
+
currency: refund.currency,
|
|
2909
|
+
...refund.failureCode ? { failureCode: refund.failureCode } : {},
|
|
2910
|
+
...refund.reason ? { reason: refund.reason } : {},
|
|
2911
|
+
createdAt: refund.createdAt
|
|
2912
|
+
}
|
|
2913
|
+
};
|
|
2914
|
+
}
|
|
2915
|
+
function settleRefund(refundId, outcome, options) {
|
|
2916
|
+
const stripe = state.stripeRefunds.get(refundId);
|
|
2917
|
+
if (stripe) settleStripeRefund(stripe, outcome, options?.failureReason);
|
|
2918
|
+
else settleBeamRefund(requireBeamRefund(refundId), outcome, options?.failureReason);
|
|
2919
|
+
}
|
|
2920
|
+
function dashboardRefund(paymentRef, amount) {
|
|
2921
|
+
const intent = state.stripePaymentIntents.get(paymentRef);
|
|
2922
|
+
if (intent) {
|
|
2923
|
+
const plan = planStripeRefund(intent, amount);
|
|
2924
|
+
if (!plan.ok) throw dashboardRefused(paymentRef, plan);
|
|
2925
|
+
return newStripeRefund({ intent, amount: plan.amount, origin: "dashboard" }).id;
|
|
2926
|
+
}
|
|
2927
|
+
const charge = state.directCharges.get(paymentRef);
|
|
2928
|
+
if (charge) {
|
|
2929
|
+
const plan = planBeamRefund(charge, amount);
|
|
2930
|
+
if (!plan.ok) throw dashboardRefused(paymentRef, plan);
|
|
2931
|
+
return newBeamRefund({ charge, amount: plan.amount, origin: "dashboard" }).refundId;
|
|
2932
|
+
}
|
|
2933
|
+
throw new Error(
|
|
2934
|
+
`[stardeck-testing] dashboardRefund: unknown payment ${paymentRef}; pass a Stripe PaymentIntent id or a Beam direct charge id`
|
|
2935
|
+
);
|
|
2936
|
+
}
|
|
2937
|
+
function dashboardRefused(paymentRef, plan) {
|
|
2938
|
+
return new Error(
|
|
2939
|
+
`[stardeck-testing] dashboardRefund of ${paymentRef} refused (${plan.code}): ${plan.message}`
|
|
2940
|
+
);
|
|
2941
|
+
}
|
|
2942
|
+
function findCheckoutSessionByIntent(url, projectId) {
|
|
2943
|
+
if (!projectId) return err("Missing authentication", 401, "unauthorized");
|
|
2944
|
+
if (!url.searchParams.get("deploymentId")) {
|
|
2945
|
+
return err("deploymentId is required", 400, "invalid_request");
|
|
2946
|
+
}
|
|
2947
|
+
const intentId = url.searchParams.get("paymentIntentId");
|
|
2948
|
+
if (!intentId) return err("paymentIntentId is required", 400, "invalid_request");
|
|
2949
|
+
const intent = state.stripePaymentIntents.get(intentId);
|
|
2950
|
+
if (intent && intent.projectId !== projectId) return json({ session: null });
|
|
2951
|
+
const session = [...state.sessionStatuses.values()].find((s) => s.paymentIntentId === intentId);
|
|
2952
|
+
return json({ session: session ?? null });
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2436
2955
|
// src/simulator/webhook-signing.ts
|
|
2437
2956
|
import crypto4 from "crypto";
|
|
2438
2957
|
function signEventDelivery(secret, context, rawBody) {
|
|
@@ -2492,6 +3011,15 @@ function paymentOperationForRequest(request, pathname) {
|
|
|
2492
3011
|
if (request.method === "GET" && /^\/api\/store\/(?!beam\/)[^/]+\/checkout-sessions\/[^/]+$/.test(pathname)) {
|
|
2493
3012
|
return "stripe.getCheckoutSessionStatus";
|
|
2494
3013
|
}
|
|
3014
|
+
if (request.method === "POST" && /^\/api\/store\/beam\/[^/]+\/charges\/[^/]+\/refunds$/.test(pathname)) {
|
|
3015
|
+
return "beam.refundCharge";
|
|
3016
|
+
}
|
|
3017
|
+
if (request.method === "GET" && /^\/api\/store\/beam\/[^/]+\/refunds\/[^/]+$/.test(pathname)) {
|
|
3018
|
+
return "beam.getRefund";
|
|
3019
|
+
}
|
|
3020
|
+
if (request.method === "GET" && /^\/api\/store\/(?!beam\/)[^/]+\/checkout-sessions$/.test(pathname)) {
|
|
3021
|
+
return "stripe.findCheckoutSessionByIntent";
|
|
3022
|
+
}
|
|
2495
3023
|
return stripeInlineOperation(request.method, pathname);
|
|
2496
3024
|
}
|
|
2497
3025
|
var STRIPE_INLINE_OPERATIONS = [
|
|
@@ -2503,6 +3031,12 @@ var STRIPE_INLINE_OPERATIONS = [
|
|
|
2503
3031
|
/^\/api\/store\/(?!beam\/)[^/]+\/payment-intents\/[^/]+\/cancel$/,
|
|
2504
3032
|
"stripe.cancelPaymentIntent"
|
|
2505
3033
|
],
|
|
3034
|
+
[
|
|
3035
|
+
"POST",
|
|
3036
|
+
/^\/api\/store\/(?!beam\/)[^/]+\/payment-intents\/[^/]+\/refunds$/,
|
|
3037
|
+
"stripe.refundPaymentIntent"
|
|
3038
|
+
],
|
|
3039
|
+
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/refunds\/[^/]+$/, "stripe.getRefund"],
|
|
2506
3040
|
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/elements-config$/, "stripe.getElementsConfig"],
|
|
2507
3041
|
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/readiness$/, "stripe.getReadiness"]
|
|
2508
3042
|
];
|
|
@@ -2587,7 +3121,8 @@ function seedStripeSession(id, body, expiresAt) {
|
|
|
2587
3121
|
currency,
|
|
2588
3122
|
customerEmail: body.customerEmail ? String(body.customerEmail) : null,
|
|
2589
3123
|
metadata: body.metadata ?? {},
|
|
2590
|
-
expiresAt
|
|
3124
|
+
expiresAt,
|
|
3125
|
+
paymentIntentId: null
|
|
2591
3126
|
});
|
|
2592
3127
|
}
|
|
2593
3128
|
function seedBeamLink(id, body, merchantId) {
|
|
@@ -2693,6 +3228,10 @@ async function handlePaymentsRequest(request, url, signedProjectId = null) {
|
|
|
2693
3228
|
signedProjectId
|
|
2694
3229
|
);
|
|
2695
3230
|
if (inlineStripeResponse) return inlineStripeResponse;
|
|
3231
|
+
const stripeRefundResponse = await handleStripeRefundRequest(request, url, signedProjectId);
|
|
3232
|
+
if (stripeRefundResponse) return stripeRefundResponse;
|
|
3233
|
+
const beamRefundResponse = await handleBeamRefundRequest(request, url);
|
|
3234
|
+
if (beamRefundResponse) return beamRefundResponse;
|
|
2696
3235
|
const directChargeResponse = await handleDirectChargeRequest(request, pathname);
|
|
2697
3236
|
if (directChargeResponse) return directChargeResponse;
|
|
2698
3237
|
if (/\/billing-portal$/.test(pathname)) {
|
|
@@ -3014,6 +3553,9 @@ async function handlePaymentsRequest(request, url, signedProjectId = null) {
|
|
|
3014
3553
|
if (scopedKey) state.idempotentCheckouts.set(scopedKey, { fingerprint, response });
|
|
3015
3554
|
return json(response);
|
|
3016
3555
|
}
|
|
3556
|
+
if (subPath === "checkout-sessions" && request.method === "GET") {
|
|
3557
|
+
return findCheckoutSessionByIntent(url, signedProjectId);
|
|
3558
|
+
}
|
|
3017
3559
|
const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
|
|
3018
3560
|
if (sessionMatch && request.method === "GET") {
|
|
3019
3561
|
const session = state.sessionStatuses.get(sessionMatch[1]);
|
|
@@ -3023,6 +3565,17 @@ async function handlePaymentsRequest(request, url, signedProjectId = null) {
|
|
|
3023
3565
|
}
|
|
3024
3566
|
return payErr(`No payments simulator for ${request.method} ${pathname}`, 404);
|
|
3025
3567
|
}
|
|
3568
|
+
function attachHostedPayment(session) {
|
|
3569
|
+
if (session.paymentIntentId || session.mode !== "payment") return;
|
|
3570
|
+
if (session.amountTotal === null || session.currency === null) return;
|
|
3571
|
+
session.paymentIntentId = createHostedPaymentIntent({
|
|
3572
|
+
projectId: TEST_ENV_DEFAULTS.PROJECT_ID,
|
|
3573
|
+
sessionId: session.id,
|
|
3574
|
+
amount: session.amountTotal,
|
|
3575
|
+
currency: session.currency,
|
|
3576
|
+
metadata: session.metadata
|
|
3577
|
+
}).id;
|
|
3578
|
+
}
|
|
3026
3579
|
function requirePendingDirectCharge(chargeId) {
|
|
3027
3580
|
const charge = state.directCharges.get(chargeId);
|
|
3028
3581
|
if (!charge) {
|
|
@@ -3058,6 +3611,7 @@ function createPayments() {
|
|
|
3058
3611
|
if (session) {
|
|
3059
3612
|
session.status = "complete";
|
|
3060
3613
|
session.paymentStatus = "paid";
|
|
3614
|
+
attachHostedPayment(session);
|
|
3061
3615
|
return;
|
|
3062
3616
|
}
|
|
3063
3617
|
const link = state.paymentLinks.get(id);
|
|
@@ -3100,7 +3654,10 @@ function createPayments() {
|
|
|
3100
3654
|
state.stripeChargesEnabled = enabled;
|
|
3101
3655
|
},
|
|
3102
3656
|
async deliverStripeEvent(handler, input, options) {
|
|
3103
|
-
|
|
3657
|
+
let event;
|
|
3658
|
+
if ("paymentIntentId" in input) event = stripePaymentIntentEvent(input.paymentIntentId);
|
|
3659
|
+
else if ("refundId" in input) event = stripeRefundEvent(input.refundId, input.type);
|
|
3660
|
+
else event = input;
|
|
3104
3661
|
const envelope = {
|
|
3105
3662
|
id: `evt_test_${crypto5.randomUUID()}`,
|
|
3106
3663
|
kind: "stripe_webhook",
|
|
@@ -3113,7 +3670,8 @@ function createPayments() {
|
|
|
3113
3670
|
};
|
|
3114
3671
|
return deliverEvent(envelope, handler, options);
|
|
3115
3672
|
},
|
|
3116
|
-
async deliverBeamEvent(handler,
|
|
3673
|
+
async deliverBeamEvent(handler, input, options) {
|
|
3674
|
+
const event = "refundId" in input ? beamRefundEvent(input.refundId) : input;
|
|
3117
3675
|
const envelope = {
|
|
3118
3676
|
id: `beam_evt_test_${crypto5.randomUUID()}`,
|
|
3119
3677
|
kind: "beam_webhook",
|
|
@@ -3125,6 +3683,20 @@ function createPayments() {
|
|
|
3125
3683
|
};
|
|
3126
3684
|
return deliverEvent(envelope, handler, options);
|
|
3127
3685
|
},
|
|
3686
|
+
get stripeRefunds() {
|
|
3687
|
+
return [...state.stripeRefunds.values()];
|
|
3688
|
+
},
|
|
3689
|
+
getStripeRefund(refundId) {
|
|
3690
|
+
return state.stripeRefunds.get(refundId);
|
|
3691
|
+
},
|
|
3692
|
+
get beamRefunds() {
|
|
3693
|
+
return [...state.beamRefunds.values()];
|
|
3694
|
+
},
|
|
3695
|
+
getBeamRefund(refundId) {
|
|
3696
|
+
return state.beamRefunds.get(refundId);
|
|
3697
|
+
},
|
|
3698
|
+
settleRefund,
|
|
3699
|
+
dashboardRefund,
|
|
3128
3700
|
approveBoltIntent(intentId, options) {
|
|
3129
3701
|
const intent = requirePendingBoltIntent(intentId);
|
|
3130
3702
|
const chargeId = options?.chargeId ?? nextBoltChargeId();
|
|
@@ -3219,6 +3791,12 @@ function createPayments() {
|
|
|
3219
3791
|
state.stripePaymentIntents.clear();
|
|
3220
3792
|
state.stripePaymentIntentCounter = 0;
|
|
3221
3793
|
state.stripeIdempotentIntents.clear();
|
|
3794
|
+
state.stripeRefunds.clear();
|
|
3795
|
+
state.stripeRefundCounter = 0;
|
|
3796
|
+
state.stripeIdempotentRefunds.clear();
|
|
3797
|
+
state.beamRefunds.clear();
|
|
3798
|
+
state.beamRefundCounter = 0;
|
|
3799
|
+
state.beamIdempotentRefunds.clear();
|
|
3222
3800
|
state.stripeAllowedMethods = ["card", "promptpay"];
|
|
3223
3801
|
state.stripeChargesEnabled = true;
|
|
3224
3802
|
},
|
|
@@ -4000,6 +4578,7 @@ var DEFAULTS = {
|
|
|
4000
4578
|
"flag.paymentsSetupStep": false,
|
|
4001
4579
|
"plan.locations": { included: 3, max: null, extraMonthly: { amount: 990, currency: "THB" } },
|
|
4002
4580
|
"plan.multiEntity": true,
|
|
4581
|
+
"pos.pro": { locations: null },
|
|
4003
4582
|
"payments.provider": "beam",
|
|
4004
4583
|
"payments.methods": { card: true, promptPay: true }
|
|
4005
4584
|
};
|