@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.js
CHANGED
|
@@ -360,6 +360,12 @@ var state = globalSingleton("state", () => ({
|
|
|
360
360
|
stripePaymentIntents: /* @__PURE__ */ new Map(),
|
|
361
361
|
stripePaymentIntentCounter: 0,
|
|
362
362
|
stripeIdempotentIntents: /* @__PURE__ */ new Map(),
|
|
363
|
+
stripeRefunds: /* @__PURE__ */ new Map(),
|
|
364
|
+
stripeRefundCounter: 0,
|
|
365
|
+
stripeIdempotentRefunds: /* @__PURE__ */ new Map(),
|
|
366
|
+
beamRefunds: /* @__PURE__ */ new Map(),
|
|
367
|
+
beamRefundCounter: 0,
|
|
368
|
+
beamIdempotentRefunds: /* @__PURE__ */ new Map(),
|
|
363
369
|
stripeAllowedMethods: ["card", "promptpay"],
|
|
364
370
|
stripeChargesEnabled: true,
|
|
365
371
|
uploads: [],
|
|
@@ -2134,7 +2140,11 @@ function decodeNextRedirect(error) {
|
|
|
2134
2140
|
var SIM_STRIPE_PUBLISHABLE_KEY = "pk_test_sim_stripe";
|
|
2135
2141
|
var SIM_STRIPE_DEFAULT_ACCOUNT = "acct_sim_default";
|
|
2136
2142
|
var METHODS = ["card", "promptpay"];
|
|
2137
|
-
var RESERVED_METADATA_KEYS = [
|
|
2143
|
+
var RESERVED_METADATA_KEYS = [
|
|
2144
|
+
"projectId",
|
|
2145
|
+
"stardeckReferenceId",
|
|
2146
|
+
"stardeckStagingDeploymentId"
|
|
2147
|
+
];
|
|
2138
2148
|
var PROMPTPAY_QR_TTL_SECONDS = 15 * 60;
|
|
2139
2149
|
var PATH_RE = /^\/api\/store\/(?!beam\/)([^/]+)\/(payment-intents(?:\/([^/]+)(?:\/(cancel))?)?|elements-config|readiness)$/;
|
|
2140
2150
|
function err(error, status, code) {
|
|
@@ -2335,6 +2345,36 @@ function createIntent(body, account, projectId) {
|
|
|
2335
2345
|
);
|
|
2336
2346
|
return json({ paymentIntent: toStripePaymentIntentSummary(intent) }, 201);
|
|
2337
2347
|
}
|
|
2348
|
+
function createHostedPaymentIntent(input) {
|
|
2349
|
+
state.stripePaymentIntentCounter += 1;
|
|
2350
|
+
const id = `pi_sim_${state.stripePaymentIntentCounter}`;
|
|
2351
|
+
const metadata = appMetadata(input.metadata);
|
|
2352
|
+
const intent = {
|
|
2353
|
+
id,
|
|
2354
|
+
projectId: input.projectId,
|
|
2355
|
+
account: "default",
|
|
2356
|
+
connectedAccountId: resolveAccount("default"),
|
|
2357
|
+
playground: false,
|
|
2358
|
+
status: "succeeded",
|
|
2359
|
+
amount: input.amount,
|
|
2360
|
+
currency: input.currency.toLowerCase(),
|
|
2361
|
+
method: "card",
|
|
2362
|
+
referenceId: input.sessionId,
|
|
2363
|
+
appMetadata: metadata,
|
|
2364
|
+
metadata: { ...metadata, projectId: input.projectId },
|
|
2365
|
+
description: null,
|
|
2366
|
+
receiptEmail: null,
|
|
2367
|
+
returnUrl: null,
|
|
2368
|
+
clientSecret: `${id}_secret_sim`,
|
|
2369
|
+
qr: null,
|
|
2370
|
+
latestChargeId: `ch_sim_${id}`,
|
|
2371
|
+
amountRefunded: 0,
|
|
2372
|
+
lastPaymentError: null,
|
|
2373
|
+
created: Math.floor(Date.now() / 1e3)
|
|
2374
|
+
};
|
|
2375
|
+
state.stripePaymentIntents.set(id, intent);
|
|
2376
|
+
return intent;
|
|
2377
|
+
}
|
|
2338
2378
|
function ownedIntent(id, projectId) {
|
|
2339
2379
|
const intent = state.stripePaymentIntents.get(id);
|
|
2340
2380
|
return intent && intent.projectId === projectId ? intent : void 0;
|
|
@@ -2472,6 +2512,485 @@ function stripePaymentIntentEvent(id) {
|
|
|
2472
2512
|
};
|
|
2473
2513
|
}
|
|
2474
2514
|
|
|
2515
|
+
// src/simulator/refunds.ts
|
|
2516
|
+
var REFUND_ROW_KEY = "refundRowId";
|
|
2517
|
+
var REFUND_METADATA_MAX_KEYS = 50 - RESERVED_METADATA_KEYS.length - 1;
|
|
2518
|
+
var REFUND_REASONS = ["duplicate", "fraudulent", "requested_by_customer"];
|
|
2519
|
+
var PROMPTPAY_BANK_DETAILS_WINDOW_SECONDS = 7 * 24 * 60 * 60;
|
|
2520
|
+
var BEAM_PARTIAL_METHODS = ["CARD", "CARD_TOKEN"];
|
|
2521
|
+
function refundFailure(plan) {
|
|
2522
|
+
return err(plan.message, plan.status, plan.code);
|
|
2523
|
+
}
|
|
2524
|
+
function isPositiveInt(value) {
|
|
2525
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
2526
|
+
}
|
|
2527
|
+
function hasValidKey(value) {
|
|
2528
|
+
return typeof value === "string" && value.length >= 1 && value.length <= 255;
|
|
2529
|
+
}
|
|
2530
|
+
function isValidDeploymentId(value) {
|
|
2531
|
+
return typeof value === "string" && UUID_RE2.test(value);
|
|
2532
|
+
}
|
|
2533
|
+
function isValidRefundMetadata(value) {
|
|
2534
|
+
if (!isStringRecord(value)) return false;
|
|
2535
|
+
const entries = Object.entries(value);
|
|
2536
|
+
return entries.length <= REFUND_METADATA_MAX_KEYS && entries.every(
|
|
2537
|
+
([k, v]) => k.length >= 1 && k.length <= 40 && v.length <= 500 && !RESERVED_METADATA_KEYS.includes(k) && k !== REFUND_ROW_KEY
|
|
2538
|
+
);
|
|
2539
|
+
}
|
|
2540
|
+
var COUNTED_STRIPE_STATUSES = [
|
|
2541
|
+
"pending",
|
|
2542
|
+
"requires_action",
|
|
2543
|
+
"succeeded"
|
|
2544
|
+
];
|
|
2545
|
+
function refundedAmount(intentId) {
|
|
2546
|
+
let total = 0;
|
|
2547
|
+
for (const refund of state.stripeRefunds.values()) {
|
|
2548
|
+
if (refund.paymentIntentId === intentId && COUNTED_STRIPE_STATUSES.includes(refund.status)) {
|
|
2549
|
+
total += refund.amount;
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
return total;
|
|
2553
|
+
}
|
|
2554
|
+
function syncAmountRefunded(intent) {
|
|
2555
|
+
intent.amountRefunded = refundedAmount(intent.id);
|
|
2556
|
+
}
|
|
2557
|
+
function validateStripeRefund(body) {
|
|
2558
|
+
const checks = [
|
|
2559
|
+
[isValidDeploymentId(body.deploymentId), "Invalid deployment ID"],
|
|
2560
|
+
[hasValidKey(body.idempotencyKey), "idempotencyKey is required (1 to 255 characters)"],
|
|
2561
|
+
[isOptional(body.amount, isPositiveInt), "amount must be a positive integer in minor units"],
|
|
2562
|
+
[
|
|
2563
|
+
isOptional(body.reason, (v) => typeof v === "string" && REFUND_REASONS.includes(v)),
|
|
2564
|
+
`reason must be one of: ${REFUND_REASONS.join(", ")}`
|
|
2565
|
+
],
|
|
2566
|
+
[isOptional(body.instructionsEmail, isEmail), "instructionsEmail must be an email address"],
|
|
2567
|
+
[
|
|
2568
|
+
isOptional(
|
|
2569
|
+
body.refundRowId,
|
|
2570
|
+
(v) => typeof v === "string" && v.length >= 1 && v.length <= 500
|
|
2571
|
+
),
|
|
2572
|
+
"refundRowId must be 1 to 500 characters"
|
|
2573
|
+
],
|
|
2574
|
+
[
|
|
2575
|
+
isOptional(body.metadata, isValidRefundMetadata),
|
|
2576
|
+
`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)`
|
|
2577
|
+
]
|
|
2578
|
+
];
|
|
2579
|
+
const failed = checks.find(([ok]) => !ok);
|
|
2580
|
+
return failed ? failed[1] : null;
|
|
2581
|
+
}
|
|
2582
|
+
function planStripeRefund(intent, requested) {
|
|
2583
|
+
if (intent.status !== "succeeded") {
|
|
2584
|
+
return {
|
|
2585
|
+
ok: false,
|
|
2586
|
+
status: 409,
|
|
2587
|
+
code: "payment_not_refundable",
|
|
2588
|
+
message: "This payment hasn't succeeded, so there is nothing to refund"
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
const remaining = intent.amount - refundedAmount(intent.id);
|
|
2592
|
+
const amount = requested ?? remaining;
|
|
2593
|
+
if (remaining <= 0 || amount > remaining) {
|
|
2594
|
+
return {
|
|
2595
|
+
ok: false,
|
|
2596
|
+
status: 409,
|
|
2597
|
+
code: "refund_exceeds_remaining",
|
|
2598
|
+
message: `Only ${remaining} can still be refunded on this payment`
|
|
2599
|
+
};
|
|
2600
|
+
}
|
|
2601
|
+
return { ok: true, amount };
|
|
2602
|
+
}
|
|
2603
|
+
function toStripeRefundSummary(refund) {
|
|
2604
|
+
const intent = state.stripePaymentIntents.get(refund.paymentIntentId);
|
|
2605
|
+
return {
|
|
2606
|
+
id: refund.id,
|
|
2607
|
+
paymentIntentId: refund.paymentIntentId,
|
|
2608
|
+
amount: refund.amount,
|
|
2609
|
+
currency: refund.currency,
|
|
2610
|
+
status: refund.status,
|
|
2611
|
+
failureReason: refund.failureReason,
|
|
2612
|
+
pendingReason: refund.pendingReason,
|
|
2613
|
+
nextActionExpiresAt: refund.nextActionExpiresAt,
|
|
2614
|
+
refundRowId: refund.refundRowId,
|
|
2615
|
+
metadata: { ...refund.appMetadata },
|
|
2616
|
+
created: refund.created,
|
|
2617
|
+
livemode: false,
|
|
2618
|
+
playground: intent?.playground ?? false
|
|
2619
|
+
};
|
|
2620
|
+
}
|
|
2621
|
+
function newStripeRefund(input) {
|
|
2622
|
+
state.stripeRefundCounter += 1;
|
|
2623
|
+
const refund = {
|
|
2624
|
+
id: `re_sim_${state.stripeRefundCounter}`,
|
|
2625
|
+
paymentIntentId: input.intent.id,
|
|
2626
|
+
projectId: input.intent.projectId,
|
|
2627
|
+
amount: input.amount,
|
|
2628
|
+
currency: input.intent.currency,
|
|
2629
|
+
// A refund made in the Stripe dashboard is already done; the app's waits for `settleRefund`.
|
|
2630
|
+
status: input.origin === "dashboard" ? "succeeded" : "pending",
|
|
2631
|
+
failureReason: null,
|
|
2632
|
+
pendingReason: null,
|
|
2633
|
+
nextActionExpiresAt: null,
|
|
2634
|
+
refundRowId: input.refundRowId ?? null,
|
|
2635
|
+
appMetadata: input.appMetadata ?? {},
|
|
2636
|
+
reason: input.reason ?? null,
|
|
2637
|
+
origin: input.origin,
|
|
2638
|
+
revision: 0,
|
|
2639
|
+
created: Math.floor(Date.now() / 1e3)
|
|
2640
|
+
};
|
|
2641
|
+
state.stripeRefunds.set(refund.id, refund);
|
|
2642
|
+
syncAmountRefunded(input.intent);
|
|
2643
|
+
console.log(
|
|
2644
|
+
`[stardeck-testing] stripe refund ${refund.id} created payment=${refund.paymentIntentId} amount=${refund.amount} origin=${refund.origin}`
|
|
2645
|
+
);
|
|
2646
|
+
return refund;
|
|
2647
|
+
}
|
|
2648
|
+
function createStripeRefund(body, intent, projectId) {
|
|
2649
|
+
const scopedKey = JSON.stringify(["refund", projectId, intent.account, body.idempotencyKey]);
|
|
2650
|
+
const fingerprint = stableStringify({
|
|
2651
|
+
paymentIntentId: intent.id,
|
|
2652
|
+
amount: body.amount,
|
|
2653
|
+
reason: body.reason,
|
|
2654
|
+
instructionsEmail: body.instructionsEmail,
|
|
2655
|
+
refundRowId: body.refundRowId,
|
|
2656
|
+
metadata: body.metadata
|
|
2657
|
+
});
|
|
2658
|
+
const saved = state.stripeIdempotentRefunds.get(scopedKey);
|
|
2659
|
+
if (saved) {
|
|
2660
|
+
if (saved.fingerprint !== fingerprint) {
|
|
2661
|
+
return err(
|
|
2662
|
+
"This idempotency key was already used with different parameters. Use a new key for a different refund.",
|
|
2663
|
+
409,
|
|
2664
|
+
"idempotency_conflict"
|
|
2665
|
+
);
|
|
2666
|
+
}
|
|
2667
|
+
return json({ refund: toStripeRefundSummary(state.stripeRefunds.get(saved.id)) });
|
|
2668
|
+
}
|
|
2669
|
+
const plan = planStripeRefund(intent, body.amount);
|
|
2670
|
+
if (!plan.ok) return refundFailure(plan);
|
|
2671
|
+
const refund = newStripeRefund({
|
|
2672
|
+
intent,
|
|
2673
|
+
amount: plan.amount,
|
|
2674
|
+
origin: "app",
|
|
2675
|
+
refundRowId: typeof body.refundRowId === "string" ? body.refundRowId : null,
|
|
2676
|
+
appMetadata: appMetadata(body.metadata),
|
|
2677
|
+
reason: typeof body.reason === "string" ? body.reason : null
|
|
2678
|
+
});
|
|
2679
|
+
state.stripeIdempotentRefunds.set(scopedKey, { fingerprint, id: refund.id });
|
|
2680
|
+
return json({ refund: toStripeRefundSummary(refund) });
|
|
2681
|
+
}
|
|
2682
|
+
var STRIPE_REFUND_PATH = /^\/api\/store\/(?!beam\/)[^/]+\/(?:payment-intents\/([^/]+)\/refunds|refunds\/([^/]+))$/;
|
|
2683
|
+
async function handleStripeRefundRequest(request, url, projectId) {
|
|
2684
|
+
const match = url.pathname.match(STRIPE_REFUND_PATH);
|
|
2685
|
+
if (!match) return null;
|
|
2686
|
+
const [, intentId, refundId] = match;
|
|
2687
|
+
const isCreate = request.method === "POST" && intentId !== void 0;
|
|
2688
|
+
const isRead = request.method === "GET" && refundId !== void 0;
|
|
2689
|
+
if (!isCreate && !isRead) return null;
|
|
2690
|
+
if (!projectId) return err("Missing authentication", 401, "unauthorized");
|
|
2691
|
+
if (isRead) {
|
|
2692
|
+
if (!url.searchParams.get("deploymentId")) {
|
|
2693
|
+
return err("deploymentId is required", 400, "invalid_request");
|
|
2694
|
+
}
|
|
2695
|
+
const refund = state.stripeRefunds.get(decode(refundId));
|
|
2696
|
+
if (!refund || refund.projectId !== projectId) {
|
|
2697
|
+
return err("Refund not found", 404, "refund_not_found");
|
|
2698
|
+
}
|
|
2699
|
+
return json({ refund: toStripeRefundSummary(refund) });
|
|
2700
|
+
}
|
|
2701
|
+
const body = await readJsonBody(request);
|
|
2702
|
+
const problem = validateStripeRefund(body);
|
|
2703
|
+
if (problem) return err(problem, 400, "invalid_request");
|
|
2704
|
+
const intent = ownedIntent(decode(intentId), projectId);
|
|
2705
|
+
if (!intent) return err("No such payment for this app", 404, "payment_not_owned");
|
|
2706
|
+
return createStripeRefund(body, intent, projectId);
|
|
2707
|
+
}
|
|
2708
|
+
function requireStripeRefund(id) {
|
|
2709
|
+
const refund = state.stripeRefunds.get(id);
|
|
2710
|
+
if (!refund) throw new Error(`[stardeck-testing] Unknown Stripe refund id: ${id}`);
|
|
2711
|
+
return refund;
|
|
2712
|
+
}
|
|
2713
|
+
function settleStripeRefund(refund, outcome, failureReason) {
|
|
2714
|
+
const intent = state.stripePaymentIntents.get(refund.paymentIntentId);
|
|
2715
|
+
const settled = refund.status === "failed" || refund.status === "canceled";
|
|
2716
|
+
if (settled || outcome !== "failed" && refund.status === "succeeded") {
|
|
2717
|
+
throw new Error(
|
|
2718
|
+
`[stardeck-testing] Can't settle Stripe refund ${refund.id} as ${outcome}: it is ${refund.status}`
|
|
2719
|
+
);
|
|
2720
|
+
}
|
|
2721
|
+
if (outcome === "needs_customer") {
|
|
2722
|
+
if (intent.method !== "promptpay") {
|
|
2723
|
+
throw new Error(
|
|
2724
|
+
`[stardeck-testing] Stripe refund ${refund.id} is on a ${intent.method} payment; only PromptPay refunds wait for the customer's bank details`
|
|
2725
|
+
);
|
|
2726
|
+
}
|
|
2727
|
+
refund.status = "requires_action";
|
|
2728
|
+
refund.nextActionExpiresAt = refund.created + PROMPTPAY_BANK_DETAILS_WINDOW_SECONDS;
|
|
2729
|
+
} else {
|
|
2730
|
+
refund.status = outcome;
|
|
2731
|
+
refund.nextActionExpiresAt = null;
|
|
2732
|
+
refund.pendingReason = null;
|
|
2733
|
+
refund.failureReason = outcome === "failed" ? failureReason ?? "unknown" : null;
|
|
2734
|
+
}
|
|
2735
|
+
refund.revision += 1;
|
|
2736
|
+
syncAmountRefunded(intent);
|
|
2737
|
+
return refund;
|
|
2738
|
+
}
|
|
2739
|
+
function stripeRefundEvent(id, type) {
|
|
2740
|
+
const refund = requireStripeRefund(id);
|
|
2741
|
+
const intent = state.stripePaymentIntents.get(refund.paymentIntentId);
|
|
2742
|
+
let eventType = type;
|
|
2743
|
+
if (!eventType) {
|
|
2744
|
+
if (refund.status === "failed") eventType = "refund.failed";
|
|
2745
|
+
else eventType = refund.revision === 0 ? "refund.created" : "refund.updated";
|
|
2746
|
+
}
|
|
2747
|
+
const metadata = { ...refund.appMetadata };
|
|
2748
|
+
if (refund.origin === "app") metadata.projectId = refund.projectId;
|
|
2749
|
+
if (refund.refundRowId) metadata[REFUND_ROW_KEY] = refund.refundRowId;
|
|
2750
|
+
return {
|
|
2751
|
+
type: eventType,
|
|
2752
|
+
accountId: intent.connectedAccountId ?? "acct_stardeck_playground",
|
|
2753
|
+
data: {
|
|
2754
|
+
id: refund.id,
|
|
2755
|
+
object: "refund",
|
|
2756
|
+
amount: refund.amount,
|
|
2757
|
+
currency: refund.currency,
|
|
2758
|
+
payment_intent: refund.paymentIntentId,
|
|
2759
|
+
charge: intent.latestChargeId,
|
|
2760
|
+
status: refund.status,
|
|
2761
|
+
reason: refund.reason,
|
|
2762
|
+
failure_reason: refund.failureReason,
|
|
2763
|
+
pending_reason: refund.pendingReason,
|
|
2764
|
+
next_action: refund.status === "requires_action" ? {
|
|
2765
|
+
type: "display_details",
|
|
2766
|
+
display_details: { expires_at: refund.nextActionExpiresAt }
|
|
2767
|
+
} : null,
|
|
2768
|
+
metadata,
|
|
2769
|
+
created: refund.created,
|
|
2770
|
+
livemode: false
|
|
2771
|
+
}
|
|
2772
|
+
};
|
|
2773
|
+
}
|
|
2774
|
+
function refundsOfCharge(chargeId) {
|
|
2775
|
+
return [...state.beamRefunds.values()].filter((r) => r.chargeId === chargeId);
|
|
2776
|
+
}
|
|
2777
|
+
function planBeamRefund(charge, requested) {
|
|
2778
|
+
if (charge.status !== "SUCCEEDED") {
|
|
2779
|
+
return {
|
|
2780
|
+
ok: false,
|
|
2781
|
+
status: 409,
|
|
2782
|
+
code: "payment_not_refundable",
|
|
2783
|
+
message: "This charge hasn't succeeded, so there is nothing to refund"
|
|
2784
|
+
};
|
|
2785
|
+
}
|
|
2786
|
+
const refunds = refundsOfCharge(charge.id);
|
|
2787
|
+
if (refunds.some((r) => r.status === "PENDING")) {
|
|
2788
|
+
return {
|
|
2789
|
+
ok: false,
|
|
2790
|
+
status: 409,
|
|
2791
|
+
code: "refund_in_progress",
|
|
2792
|
+
message: "This charge already has a refund in progress. Wait for it to finish."
|
|
2793
|
+
};
|
|
2794
|
+
}
|
|
2795
|
+
const refunded = refunds.filter((r) => r.status === "SUCCEEDED").reduce((n, r) => n + r.amount, 0);
|
|
2796
|
+
const remaining = charge.amount - refunded;
|
|
2797
|
+
const amount = requested ?? remaining;
|
|
2798
|
+
if (remaining <= 0 || amount > remaining) {
|
|
2799
|
+
return {
|
|
2800
|
+
ok: false,
|
|
2801
|
+
status: 409,
|
|
2802
|
+
code: "refund_exceeds_remaining",
|
|
2803
|
+
message: `Only ${remaining} can still be refunded on this charge`
|
|
2804
|
+
};
|
|
2805
|
+
}
|
|
2806
|
+
if (amount < charge.amount && !BEAM_PARTIAL_METHODS.includes(charge.paymentMethodType)) {
|
|
2807
|
+
return {
|
|
2808
|
+
ok: false,
|
|
2809
|
+
status: 409,
|
|
2810
|
+
code: "refund_partial_not_allowed",
|
|
2811
|
+
message: `${charge.paymentMethodType} charges can only be refunded in full`
|
|
2812
|
+
};
|
|
2813
|
+
}
|
|
2814
|
+
return { ok: true, amount };
|
|
2815
|
+
}
|
|
2816
|
+
function toBeamRefundSummary(refund) {
|
|
2817
|
+
return {
|
|
2818
|
+
refundId: refund.refundId,
|
|
2819
|
+
chargeId: refund.chargeId,
|
|
2820
|
+
amount: refund.amount,
|
|
2821
|
+
currency: refund.currency,
|
|
2822
|
+
status: refund.status,
|
|
2823
|
+
failureCode: refund.failureCode,
|
|
2824
|
+
reason: refund.reason,
|
|
2825
|
+
createdAt: refund.createdAt
|
|
2826
|
+
};
|
|
2827
|
+
}
|
|
2828
|
+
function newBeamRefund(input) {
|
|
2829
|
+
state.beamRefundCounter += 1;
|
|
2830
|
+
const refund = {
|
|
2831
|
+
refundId: `rf_sim_${state.beamRefundCounter}`,
|
|
2832
|
+
chargeId: input.charge.id,
|
|
2833
|
+
amount: input.amount,
|
|
2834
|
+
currency: input.charge.currency,
|
|
2835
|
+
status: input.origin === "dashboard" ? "SUCCEEDED" : "PENDING",
|
|
2836
|
+
failureCode: null,
|
|
2837
|
+
reason: input.reason ?? null,
|
|
2838
|
+
origin: input.origin,
|
|
2839
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2840
|
+
};
|
|
2841
|
+
state.beamRefunds.set(refund.refundId, refund);
|
|
2842
|
+
console.log(
|
|
2843
|
+
`[stardeck-testing] beam refund ${refund.refundId} created charge=${refund.chargeId} amount=${refund.amount} origin=${refund.origin}`
|
|
2844
|
+
);
|
|
2845
|
+
return refund;
|
|
2846
|
+
}
|
|
2847
|
+
function validateBeamRefund(body) {
|
|
2848
|
+
const checks = [
|
|
2849
|
+
[isValidDeploymentId(body.deploymentId), "Invalid deployment ID"],
|
|
2850
|
+
[hasValidKey(body.idempotencyKey), "idempotencyKey is required (1 to 255 characters)"],
|
|
2851
|
+
[isOptional(body.amount, isPositiveInt), "amount must be a positive integer in satang"],
|
|
2852
|
+
[
|
|
2853
|
+
isOptional(body.reason, (v) => typeof v === "string" && v.length <= 500),
|
|
2854
|
+
"reason must be at most 500 characters"
|
|
2855
|
+
]
|
|
2856
|
+
];
|
|
2857
|
+
const failed = checks.find(([ok]) => !ok);
|
|
2858
|
+
return failed ? failed[1] : null;
|
|
2859
|
+
}
|
|
2860
|
+
function createBeamRefund(body, charge, merchant) {
|
|
2861
|
+
const scopedKey = JSON.stringify(["beam-refund", merchant, body.idempotencyKey]);
|
|
2862
|
+
const fingerprint = stableStringify({
|
|
2863
|
+
chargeId: charge.id,
|
|
2864
|
+
amount: body.amount,
|
|
2865
|
+
reason: body.reason
|
|
2866
|
+
});
|
|
2867
|
+
const saved = state.beamIdempotentRefunds.get(scopedKey);
|
|
2868
|
+
if (saved) {
|
|
2869
|
+
if (saved.fingerprint !== fingerprint) {
|
|
2870
|
+
return err(
|
|
2871
|
+
"This idempotency key was already used with different parameters. Use a new key for a different refund.",
|
|
2872
|
+
409,
|
|
2873
|
+
"idempotency_conflict"
|
|
2874
|
+
);
|
|
2875
|
+
}
|
|
2876
|
+
return json({ refund: toBeamRefundSummary(state.beamRefunds.get(saved.id)) });
|
|
2877
|
+
}
|
|
2878
|
+
const plan = planBeamRefund(charge, body.amount);
|
|
2879
|
+
if (!plan.ok) return refundFailure(plan);
|
|
2880
|
+
const refund = newBeamRefund({
|
|
2881
|
+
charge,
|
|
2882
|
+
amount: plan.amount,
|
|
2883
|
+
origin: "app",
|
|
2884
|
+
reason: typeof body.reason === "string" ? body.reason : null
|
|
2885
|
+
});
|
|
2886
|
+
state.beamIdempotentRefunds.set(scopedKey, { fingerprint, id: refund.refundId });
|
|
2887
|
+
return json({ refund: toBeamRefundSummary(refund) });
|
|
2888
|
+
}
|
|
2889
|
+
var BEAM_REFUND_PATH = /^\/api\/store\/beam\/([^/]+)\/(?:charges\/([^/]+)\/refunds|refunds\/([^/]+))$/;
|
|
2890
|
+
async function handleBeamRefundRequest(request, url) {
|
|
2891
|
+
const match = url.pathname.match(BEAM_REFUND_PATH);
|
|
2892
|
+
if (!match) return null;
|
|
2893
|
+
const [, merchant, chargeId, refundId] = match;
|
|
2894
|
+
const isCreate = request.method === "POST" && chargeId !== void 0;
|
|
2895
|
+
const isRead = request.method === "GET" && refundId !== void 0;
|
|
2896
|
+
if (!isCreate && !isRead) return null;
|
|
2897
|
+
if (isRead) {
|
|
2898
|
+
if (!url.searchParams.get("deploymentId")) {
|
|
2899
|
+
return err("deploymentId is required", 400, "invalid_request");
|
|
2900
|
+
}
|
|
2901
|
+
const refund = state.beamRefunds.get(decode(refundId));
|
|
2902
|
+
if (!refund) return err("Refund not found", 404, "refund_not_found");
|
|
2903
|
+
return json({ refund: toBeamRefundSummary(refund) });
|
|
2904
|
+
}
|
|
2905
|
+
const body = await readJsonBody(request);
|
|
2906
|
+
const problem = validateBeamRefund(body);
|
|
2907
|
+
if (problem) return err(problem, 400, "invalid_request");
|
|
2908
|
+
const charge = state.directCharges.get(decode(chargeId));
|
|
2909
|
+
if (!charge) return err("No such charge for this app", 404, "payment_not_owned");
|
|
2910
|
+
return createBeamRefund(body, charge, decode(merchant));
|
|
2911
|
+
}
|
|
2912
|
+
function requireBeamRefund(id) {
|
|
2913
|
+
const refund = state.beamRefunds.get(id);
|
|
2914
|
+
if (!refund) throw new Error(`[stardeck-testing] Unknown Beam refund id: ${id}`);
|
|
2915
|
+
return refund;
|
|
2916
|
+
}
|
|
2917
|
+
function settleBeamRefund(refund, outcome, failureCode) {
|
|
2918
|
+
if (outcome === "needs_customer") {
|
|
2919
|
+
throw new Error(
|
|
2920
|
+
`[stardeck-testing] Beam refund ${refund.refundId} can't need the customer: Beam refunds have no customer step (only Stripe PromptPay does)`
|
|
2921
|
+
);
|
|
2922
|
+
}
|
|
2923
|
+
if (refund.status !== "PENDING") {
|
|
2924
|
+
throw new Error(
|
|
2925
|
+
`[stardeck-testing] Can't settle Beam refund ${refund.refundId} as ${outcome}: it is ${refund.status}, and Beam refund statuses are final`
|
|
2926
|
+
);
|
|
2927
|
+
}
|
|
2928
|
+
refund.status = outcome === "succeeded" ? "SUCCEEDED" : "FAILED";
|
|
2929
|
+
refund.failureCode = outcome === "failed" ? failureCode ?? "REFUND_FAILED" : null;
|
|
2930
|
+
return refund;
|
|
2931
|
+
}
|
|
2932
|
+
function beamRefundEvent(id) {
|
|
2933
|
+
const refund = requireBeamRefund(id);
|
|
2934
|
+
if (refund.status === "PENDING") {
|
|
2935
|
+
throw new Error(
|
|
2936
|
+
`[stardeck-testing] Beam refund ${id} is PENDING and has no event yet; call settleRefund first`
|
|
2937
|
+
);
|
|
2938
|
+
}
|
|
2939
|
+
return {
|
|
2940
|
+
type: refund.status === "SUCCEEDED" ? "refund.succeeded" : "refund.failed",
|
|
2941
|
+
payload: {
|
|
2942
|
+
refundId: refund.refundId,
|
|
2943
|
+
chargeId: refund.chargeId,
|
|
2944
|
+
merchantId: SIM_BEAM_MERCHANT_ID,
|
|
2945
|
+
status: refund.status,
|
|
2946
|
+
amount: refund.amount,
|
|
2947
|
+
currency: refund.currency,
|
|
2948
|
+
...refund.failureCode ? { failureCode: refund.failureCode } : {},
|
|
2949
|
+
...refund.reason ? { reason: refund.reason } : {},
|
|
2950
|
+
createdAt: refund.createdAt
|
|
2951
|
+
}
|
|
2952
|
+
};
|
|
2953
|
+
}
|
|
2954
|
+
function settleRefund(refundId, outcome, options) {
|
|
2955
|
+
const stripe = state.stripeRefunds.get(refundId);
|
|
2956
|
+
if (stripe) settleStripeRefund(stripe, outcome, options?.failureReason);
|
|
2957
|
+
else settleBeamRefund(requireBeamRefund(refundId), outcome, options?.failureReason);
|
|
2958
|
+
}
|
|
2959
|
+
function dashboardRefund(paymentRef, amount) {
|
|
2960
|
+
const intent = state.stripePaymentIntents.get(paymentRef);
|
|
2961
|
+
if (intent) {
|
|
2962
|
+
const plan = planStripeRefund(intent, amount);
|
|
2963
|
+
if (!plan.ok) throw dashboardRefused(paymentRef, plan);
|
|
2964
|
+
return newStripeRefund({ intent, amount: plan.amount, origin: "dashboard" }).id;
|
|
2965
|
+
}
|
|
2966
|
+
const charge = state.directCharges.get(paymentRef);
|
|
2967
|
+
if (charge) {
|
|
2968
|
+
const plan = planBeamRefund(charge, amount);
|
|
2969
|
+
if (!plan.ok) throw dashboardRefused(paymentRef, plan);
|
|
2970
|
+
return newBeamRefund({ charge, amount: plan.amount, origin: "dashboard" }).refundId;
|
|
2971
|
+
}
|
|
2972
|
+
throw new Error(
|
|
2973
|
+
`[stardeck-testing] dashboardRefund: unknown payment ${paymentRef}; pass a Stripe PaymentIntent id or a Beam direct charge id`
|
|
2974
|
+
);
|
|
2975
|
+
}
|
|
2976
|
+
function dashboardRefused(paymentRef, plan) {
|
|
2977
|
+
return new Error(
|
|
2978
|
+
`[stardeck-testing] dashboardRefund of ${paymentRef} refused (${plan.code}): ${plan.message}`
|
|
2979
|
+
);
|
|
2980
|
+
}
|
|
2981
|
+
function findCheckoutSessionByIntent(url, projectId) {
|
|
2982
|
+
if (!projectId) return err("Missing authentication", 401, "unauthorized");
|
|
2983
|
+
if (!url.searchParams.get("deploymentId")) {
|
|
2984
|
+
return err("deploymentId is required", 400, "invalid_request");
|
|
2985
|
+
}
|
|
2986
|
+
const intentId = url.searchParams.get("paymentIntentId");
|
|
2987
|
+
if (!intentId) return err("paymentIntentId is required", 400, "invalid_request");
|
|
2988
|
+
const intent = state.stripePaymentIntents.get(intentId);
|
|
2989
|
+
if (intent && intent.projectId !== projectId) return json({ session: null });
|
|
2990
|
+
const session = [...state.sessionStatuses.values()].find((s) => s.paymentIntentId === intentId);
|
|
2991
|
+
return json({ session: session ?? null });
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2475
2994
|
// src/simulator/webhook-signing.ts
|
|
2476
2995
|
var import_node_crypto5 = __toESM(require("crypto"));
|
|
2477
2996
|
function signEventDelivery(secret, context, rawBody) {
|
|
@@ -2531,6 +3050,15 @@ function paymentOperationForRequest(request, pathname) {
|
|
|
2531
3050
|
if (request.method === "GET" && /^\/api\/store\/(?!beam\/)[^/]+\/checkout-sessions\/[^/]+$/.test(pathname)) {
|
|
2532
3051
|
return "stripe.getCheckoutSessionStatus";
|
|
2533
3052
|
}
|
|
3053
|
+
if (request.method === "POST" && /^\/api\/store\/beam\/[^/]+\/charges\/[^/]+\/refunds$/.test(pathname)) {
|
|
3054
|
+
return "beam.refundCharge";
|
|
3055
|
+
}
|
|
3056
|
+
if (request.method === "GET" && /^\/api\/store\/beam\/[^/]+\/refunds\/[^/]+$/.test(pathname)) {
|
|
3057
|
+
return "beam.getRefund";
|
|
3058
|
+
}
|
|
3059
|
+
if (request.method === "GET" && /^\/api\/store\/(?!beam\/)[^/]+\/checkout-sessions$/.test(pathname)) {
|
|
3060
|
+
return "stripe.findCheckoutSessionByIntent";
|
|
3061
|
+
}
|
|
2534
3062
|
return stripeInlineOperation(request.method, pathname);
|
|
2535
3063
|
}
|
|
2536
3064
|
var STRIPE_INLINE_OPERATIONS = [
|
|
@@ -2542,6 +3070,12 @@ var STRIPE_INLINE_OPERATIONS = [
|
|
|
2542
3070
|
/^\/api\/store\/(?!beam\/)[^/]+\/payment-intents\/[^/]+\/cancel$/,
|
|
2543
3071
|
"stripe.cancelPaymentIntent"
|
|
2544
3072
|
],
|
|
3073
|
+
[
|
|
3074
|
+
"POST",
|
|
3075
|
+
/^\/api\/store\/(?!beam\/)[^/]+\/payment-intents\/[^/]+\/refunds$/,
|
|
3076
|
+
"stripe.refundPaymentIntent"
|
|
3077
|
+
],
|
|
3078
|
+
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/refunds\/[^/]+$/, "stripe.getRefund"],
|
|
2545
3079
|
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/elements-config$/, "stripe.getElementsConfig"],
|
|
2546
3080
|
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/readiness$/, "stripe.getReadiness"]
|
|
2547
3081
|
];
|
|
@@ -2626,7 +3160,8 @@ function seedStripeSession(id, body, expiresAt) {
|
|
|
2626
3160
|
currency,
|
|
2627
3161
|
customerEmail: body.customerEmail ? String(body.customerEmail) : null,
|
|
2628
3162
|
metadata: body.metadata ?? {},
|
|
2629
|
-
expiresAt
|
|
3163
|
+
expiresAt,
|
|
3164
|
+
paymentIntentId: null
|
|
2630
3165
|
});
|
|
2631
3166
|
}
|
|
2632
3167
|
function seedBeamLink(id, body, merchantId) {
|
|
@@ -2732,6 +3267,10 @@ async function handlePaymentsRequest(request, url, signedProjectId = null) {
|
|
|
2732
3267
|
signedProjectId
|
|
2733
3268
|
);
|
|
2734
3269
|
if (inlineStripeResponse) return inlineStripeResponse;
|
|
3270
|
+
const stripeRefundResponse = await handleStripeRefundRequest(request, url, signedProjectId);
|
|
3271
|
+
if (stripeRefundResponse) return stripeRefundResponse;
|
|
3272
|
+
const beamRefundResponse = await handleBeamRefundRequest(request, url);
|
|
3273
|
+
if (beamRefundResponse) return beamRefundResponse;
|
|
2735
3274
|
const directChargeResponse = await handleDirectChargeRequest(request, pathname);
|
|
2736
3275
|
if (directChargeResponse) return directChargeResponse;
|
|
2737
3276
|
if (/\/billing-portal$/.test(pathname)) {
|
|
@@ -3053,6 +3592,9 @@ async function handlePaymentsRequest(request, url, signedProjectId = null) {
|
|
|
3053
3592
|
if (scopedKey) state.idempotentCheckouts.set(scopedKey, { fingerprint, response });
|
|
3054
3593
|
return json(response);
|
|
3055
3594
|
}
|
|
3595
|
+
if (subPath === "checkout-sessions" && request.method === "GET") {
|
|
3596
|
+
return findCheckoutSessionByIntent(url, signedProjectId);
|
|
3597
|
+
}
|
|
3056
3598
|
const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
|
|
3057
3599
|
if (sessionMatch && request.method === "GET") {
|
|
3058
3600
|
const session = state.sessionStatuses.get(sessionMatch[1]);
|
|
@@ -3062,6 +3604,17 @@ async function handlePaymentsRequest(request, url, signedProjectId = null) {
|
|
|
3062
3604
|
}
|
|
3063
3605
|
return payErr(`No payments simulator for ${request.method} ${pathname}`, 404);
|
|
3064
3606
|
}
|
|
3607
|
+
function attachHostedPayment(session) {
|
|
3608
|
+
if (session.paymentIntentId || session.mode !== "payment") return;
|
|
3609
|
+
if (session.amountTotal === null || session.currency === null) return;
|
|
3610
|
+
session.paymentIntentId = createHostedPaymentIntent({
|
|
3611
|
+
projectId: TEST_ENV_DEFAULTS.PROJECT_ID,
|
|
3612
|
+
sessionId: session.id,
|
|
3613
|
+
amount: session.amountTotal,
|
|
3614
|
+
currency: session.currency,
|
|
3615
|
+
metadata: session.metadata
|
|
3616
|
+
}).id;
|
|
3617
|
+
}
|
|
3065
3618
|
function requirePendingDirectCharge(chargeId) {
|
|
3066
3619
|
const charge = state.directCharges.get(chargeId);
|
|
3067
3620
|
if (!charge) {
|
|
@@ -3097,6 +3650,7 @@ function createPayments() {
|
|
|
3097
3650
|
if (session) {
|
|
3098
3651
|
session.status = "complete";
|
|
3099
3652
|
session.paymentStatus = "paid";
|
|
3653
|
+
attachHostedPayment(session);
|
|
3100
3654
|
return;
|
|
3101
3655
|
}
|
|
3102
3656
|
const link = state.paymentLinks.get(id);
|
|
@@ -3139,7 +3693,10 @@ function createPayments() {
|
|
|
3139
3693
|
state.stripeChargesEnabled = enabled;
|
|
3140
3694
|
},
|
|
3141
3695
|
async deliverStripeEvent(handler, input, options) {
|
|
3142
|
-
|
|
3696
|
+
let event;
|
|
3697
|
+
if ("paymentIntentId" in input) event = stripePaymentIntentEvent(input.paymentIntentId);
|
|
3698
|
+
else if ("refundId" in input) event = stripeRefundEvent(input.refundId, input.type);
|
|
3699
|
+
else event = input;
|
|
3143
3700
|
const envelope = {
|
|
3144
3701
|
id: `evt_test_${import_node_crypto6.default.randomUUID()}`,
|
|
3145
3702
|
kind: "stripe_webhook",
|
|
@@ -3152,7 +3709,8 @@ function createPayments() {
|
|
|
3152
3709
|
};
|
|
3153
3710
|
return deliverEvent(envelope, handler, options);
|
|
3154
3711
|
},
|
|
3155
|
-
async deliverBeamEvent(handler,
|
|
3712
|
+
async deliverBeamEvent(handler, input, options) {
|
|
3713
|
+
const event = "refundId" in input ? beamRefundEvent(input.refundId) : input;
|
|
3156
3714
|
const envelope = {
|
|
3157
3715
|
id: `beam_evt_test_${import_node_crypto6.default.randomUUID()}`,
|
|
3158
3716
|
kind: "beam_webhook",
|
|
@@ -3164,6 +3722,20 @@ function createPayments() {
|
|
|
3164
3722
|
};
|
|
3165
3723
|
return deliverEvent(envelope, handler, options);
|
|
3166
3724
|
},
|
|
3725
|
+
get stripeRefunds() {
|
|
3726
|
+
return [...state.stripeRefunds.values()];
|
|
3727
|
+
},
|
|
3728
|
+
getStripeRefund(refundId) {
|
|
3729
|
+
return state.stripeRefunds.get(refundId);
|
|
3730
|
+
},
|
|
3731
|
+
get beamRefunds() {
|
|
3732
|
+
return [...state.beamRefunds.values()];
|
|
3733
|
+
},
|
|
3734
|
+
getBeamRefund(refundId) {
|
|
3735
|
+
return state.beamRefunds.get(refundId);
|
|
3736
|
+
},
|
|
3737
|
+
settleRefund,
|
|
3738
|
+
dashboardRefund,
|
|
3167
3739
|
approveBoltIntent(intentId, options) {
|
|
3168
3740
|
const intent = requirePendingBoltIntent(intentId);
|
|
3169
3741
|
const chargeId = options?.chargeId ?? nextBoltChargeId();
|
|
@@ -3258,6 +3830,12 @@ function createPayments() {
|
|
|
3258
3830
|
state.stripePaymentIntents.clear();
|
|
3259
3831
|
state.stripePaymentIntentCounter = 0;
|
|
3260
3832
|
state.stripeIdempotentIntents.clear();
|
|
3833
|
+
state.stripeRefunds.clear();
|
|
3834
|
+
state.stripeRefundCounter = 0;
|
|
3835
|
+
state.stripeIdempotentRefunds.clear();
|
|
3836
|
+
state.beamRefunds.clear();
|
|
3837
|
+
state.beamRefundCounter = 0;
|
|
3838
|
+
state.beamIdempotentRefunds.clear();
|
|
3261
3839
|
state.stripeAllowedMethods = ["card", "promptpay"];
|
|
3262
3840
|
state.stripeChargesEnabled = true;
|
|
3263
3841
|
},
|
|
@@ -4039,6 +4617,7 @@ var DEFAULTS = {
|
|
|
4039
4617
|
"flag.paymentsSetupStep": false,
|
|
4040
4618
|
"plan.locations": { included: 3, max: null, extraMonthly: { amount: 990, currency: "THB" } },
|
|
4041
4619
|
"plan.multiEntity": true,
|
|
4620
|
+
"pos.pro": { locations: null },
|
|
4042
4621
|
"payments.provider": "beam",
|
|
4043
4622
|
"payments.methods": { card: true, promptPay: true }
|
|
4044
4623
|
};
|