Skip to content

Commit be48551

Browse files
authored
fix: checkout form field rendering + validation (#89)
1 parent 4a2258f commit be48551

35 files changed

Lines changed: 2402 additions & 739 deletions

apps/api/src/__tests__/CheckoutPayment.spec.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ describe('CheckoutPaymentModule', () => {
252252
const result = await module.PreparePayment(
253253
session.url_slug,
254254
'PayerWallet111',
255-
'buyer@example.com'
255+
{ email: 'buyer@example.com' }
256256
);
257257

258258
expect(mockSolana.BuildCheckoutPaymentTransaction).toHaveBeenCalledWith(
@@ -316,7 +316,7 @@ describe('CheckoutPaymentModule', () => {
316316
const result = await module.PreparePayment(
317317
session.url_slug,
318318
'PayerWallet111',
319-
'buyer@example.com'
319+
{ email: 'buyer@example.com' }
320320
);
321321

322322
expect(
@@ -373,7 +373,8 @@ describe('CheckoutPaymentModule', () => {
373373

374374
const result = await module.PreparePayment(
375375
session.url_slug,
376-
'PayerWallet111'
376+
'PayerWallet111',
377+
{ email: 'buyer@example.com' }
377378
);
378379

379380
expect(mockSolana.BuildSubscribeTransaction).not.toHaveBeenCalled();

apps/api/src/__tests__/PaymentLink.spec.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,29 @@ describe('PaymentLinkModule', () => {
293293
);
294294
});
295295

296+
it('should raise completed_sessions limit when reactivating an exhausted link', async () => {
297+
const existing = BuildPaymentLink({
298+
active: false,
299+
restrictions: {
300+
completed_sessions: { count: 1, limit: 1 },
301+
},
302+
});
303+
mockDb.Get = jest.fn().mockResolvedValue(existing);
304+
305+
await module.UpdatePaymentLink(existing.id, { active: true });
306+
307+
expect(mockDb.Update).toHaveBeenCalledWith(
308+
'PaymentLinks',
309+
existing.id,
310+
expect.objectContaining({
311+
active: true,
312+
restrictions: {
313+
completed_sessions: { count: 1, limit: 2 },
314+
},
315+
})
316+
);
317+
});
318+
296319
it('should update existing line items by id', async () => {
297320
const existing = BuildPaymentLink();
298321
mockDb.Get = jest.fn().mockResolvedValue(existing);
@@ -455,5 +478,39 @@ describe('PaymentLinkModule', () => {
455478
})
456479
);
457480
});
481+
482+
it('should deactivate the payment link when the limit is reached', async () => {
483+
mockDb.Get = jest
484+
.fn()
485+
.mockResolvedValueOnce(
486+
BuildPaymentLink({
487+
active: true,
488+
restrictions: {
489+
completed_sessions: { count: 0, limit: 1 },
490+
},
491+
})
492+
)
493+
.mockResolvedValueOnce(
494+
BuildPaymentLink({
495+
active: false,
496+
restrictions: {
497+
completed_sessions: { count: 1, limit: 1 },
498+
},
499+
})
500+
);
501+
502+
await module.RecordCompletedSession('plink_z_1');
503+
504+
expect(mockDb.Update).toHaveBeenCalledWith(
505+
'PaymentLinks',
506+
'plink_z_1',
507+
expect.objectContaining({
508+
active: false,
509+
restrictions: {
510+
completed_sessions: { count: 1, limit: 1 },
511+
},
512+
})
513+
);
514+
});
458515
});
459516
});

apps/api/src/modules/CheckoutPayment.ts

Lines changed: 215 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { AppError } from '../utils/AppError';
2525
import { ERRORS } from '../utils/Errors';
2626
import { Logger } from '../utils/Logger';
2727
import { Now } from '../utils/Timestamp';
28+
import { PrepareCheckoutPaymentInput } from '@zoneless/shared-schemas';
2829
import {
2930
Account,
3031
BalanceTransaction,
@@ -64,6 +65,41 @@ export interface PreparedCheckoutPayment {
6465
subscription_step?: 'init_authority' | 'subscribe';
6566
}
6667

68+
/** Convert a stored nullable address into CreateCustomer address input. */
69+
function ToCreateCustomerAddress(
70+
address:
71+
| {
72+
city: string | null;
73+
country: string | null;
74+
line1: string | null;
75+
line2: string | null;
76+
postal_code: string | null;
77+
state: string | null;
78+
}
79+
| null
80+
| undefined
81+
):
82+
| {
83+
city?: string;
84+
country?: string;
85+
line1?: string;
86+
line2?: string;
87+
postal_code?: string;
88+
state?: string;
89+
}
90+
| undefined {
91+
if (!address) return undefined;
92+
const result = {
93+
...(address.city ? { city: address.city } : {}),
94+
...(address.country ? { country: address.country } : {}),
95+
...(address.line1 ? { line1: address.line1 } : {}),
96+
...(address.line2 ? { line2: address.line2 } : {}),
97+
...(address.postal_code ? { postal_code: address.postal_code } : {}),
98+
...(address.state ? { state: address.state } : {}),
99+
};
100+
return Object.keys(result).length > 0 ? result : undefined;
101+
}
102+
67103
export class CheckoutPaymentModule {
68104
private readonly db: Database;
69105
private readonly accountModule: AccountModule;
@@ -210,7 +246,7 @@ export class CheckoutPaymentModule {
210246
async PreparePayment(
211247
urlSlug: string,
212248
payerWallet: string | undefined,
213-
email?: string
249+
customerDetails?: Omit<PrepareCheckoutPaymentInput, 'payer_wallet'>
214250
): Promise<PreparedCheckoutPayment> {
215251
if (!payerWallet || typeof payerWallet !== 'string') {
216252
throw new AppError(
@@ -223,12 +259,27 @@ export class CheckoutPaymentModule {
223259
const session = await this.GetSessionOrThrow(urlSlug);
224260
this.AssertSessionPayable(session);
225261

262+
// Existing customers with an email cannot change it at checkout.
263+
const lockedEmail = session.customer
264+
? session.customer_email?.trim() ||
265+
session.customer_details?.email?.trim() ||
266+
null
267+
: null;
268+
const resolvedDetails = lockedEmail
269+
? { ...customerDetails, email: lockedEmail }
270+
: customerDetails;
271+
272+
this.AssertCollectedCustomerDetails(session, resolvedDetails);
273+
226274
const merchantWallet = await this.ResolveMerchantWallet(
227275
session.platform_account
228276
);
229277

230-
if (email && typeof email === 'string') {
231-
await this.checkoutSessionModule.SetCustomerEmail(session.id, email);
278+
if (resolvedDetails) {
279+
await this.checkoutSessionModule.SetCollectedCustomerDetails(
280+
session.id,
281+
resolvedDetails
282+
);
232283
}
233284

234285
Logger.info('Preparing checkout transaction', {
@@ -525,6 +576,10 @@ export class CheckoutPaymentModule {
525576
latestCharge: charge?.id ?? null,
526577
});
527578

579+
if (this.ShouldCreateCheckoutCustomer(session) && this.customerModule) {
580+
await this.EnsureCheckoutCustomer(session, verification.payer_address);
581+
}
582+
528583
const completedSession =
529584
await this.checkoutSessionModule.CompleteCheckoutSession(session.id, {
530585
transaction_signature: signature,
@@ -793,9 +848,18 @@ export class CheckoutPaymentModule {
793848
);
794849
}
795850

851+
private ShouldCreateCheckoutCustomer(session: CheckoutSession): boolean {
852+
if (session.customer) return false;
853+
if (session.mode === 'subscription') return true;
854+
return (
855+
session.customer_creation === 'always' ||
856+
session.customer_creation === 'if_required'
857+
);
858+
}
859+
796860
private async EnsureCheckoutCustomer(
797861
session: CheckoutSession,
798-
subscriberWallet: string
862+
payerWallet: string
799863
): Promise<string> {
800864
if (session.customer) return session.customer;
801865
if (!this.customerModule) {
@@ -806,17 +870,56 @@ export class CheckoutPaymentModule {
806870
);
807871
}
808872

809-
const email =
810-
session.customer_email ?? session.customer_details?.email ?? undefined;
873+
const details = session.customer_details;
874+
const email = session.customer_email ?? details?.email ?? undefined;
875+
const individualName = details?.individual_name ?? undefined;
876+
const businessName = details?.business_name ?? undefined;
877+
const name = details?.name ?? individualName ?? businessName ?? undefined;
878+
const phone = details?.phone ?? undefined;
879+
const address = ToCreateCustomerAddress(details?.address);
880+
const shippingDetails = session.collected_information?.shipping_details;
881+
const shippingAddress = ToCreateCustomerAddress(shippingDetails?.address);
882+
const shipping =
883+
shippingDetails?.name && shippingAddress
884+
? {
885+
name: shippingDetails.name,
886+
address: shippingAddress,
887+
...(phone ? { phone } : {}),
888+
}
889+
: undefined;
890+
const taxIdData = (details?.tax_ids ?? [])
891+
.filter((taxId) => !!taxId.value?.trim())
892+
.map((taxId) => ({
893+
type: taxId.type?.trim() || 'unknown',
894+
value: taxId.value.trim(),
895+
}));
896+
const customFieldMetadata: Record<string, string> = {};
897+
for (const field of session.custom_fields ?? []) {
898+
const value =
899+
field.text?.value ??
900+
field.numeric?.value ??
901+
field.dropdown?.value ??
902+
null;
903+
if (!value?.trim()) continue;
904+
customFieldMetadata[`custom_field_${field.key}`] = value.trim();
905+
}
811906

812907
const customer = await this.customerModule.CreateCustomer(
813908
session.platform_account,
814909
{
815910
email,
816-
description: `Checkout subscriber ${subscriberWallet}`,
911+
name,
912+
individual_name: individualName ?? undefined,
913+
business_name: businessName ?? undefined,
914+
phone,
915+
address,
916+
shipping,
917+
...(taxIdData.length > 0 ? { tax_id_data: taxIdData } : {}),
918+
description: `Checkout customer ${payerWallet}`,
817919
metadata: {
818-
wallet_address: subscriberWallet,
920+
wallet_address: payerWallet,
819921
checkout_session: session.id,
922+
...customFieldMetadata,
820923
},
821924
}
822925
);
@@ -1035,6 +1138,110 @@ export class CheckoutPaymentModule {
10351138
}
10361139
}
10371140

1141+
/**
1142+
* Enforce required collection fields configured on the session (name,
1143+
* phone, address, tax ID, custom fields, terms) before building a payment.
1144+
*/
1145+
private AssertCollectedCustomerDetails(
1146+
session: CheckoutSession,
1147+
details?: Omit<PrepareCheckoutPaymentInput, 'payer_wallet'>
1148+
): void {
1149+
const Require = (condition: boolean, message: string): void => {
1150+
if (condition) {
1151+
throw new AppError(
1152+
message,
1153+
ERRORS.VALIDATION_ERROR.status,
1154+
ERRORS.VALIDATION_ERROR.type
1155+
);
1156+
}
1157+
};
1158+
1159+
Require(!details?.email?.trim(), 'Email is required');
1160+
1161+
const individual = session.name_collection?.individual;
1162+
Require(
1163+
!!individual?.enabled && !individual.optional && !details?.name?.trim(),
1164+
'Name is required'
1165+
);
1166+
1167+
const business = session.name_collection?.business;
1168+
Require(
1169+
!!business?.enabled &&
1170+
!business.optional &&
1171+
!details?.business_name?.trim(),
1172+
'Business name is required'
1173+
);
1174+
1175+
Require(
1176+
!!session.phone_number_collection?.enabled && !details?.phone?.trim(),
1177+
'Phone number is required'
1178+
);
1179+
1180+
const AssertAddress = (
1181+
required: boolean,
1182+
address:
1183+
| {
1184+
line1?: string;
1185+
city?: string;
1186+
postal_code?: string;
1187+
country?: string;
1188+
}
1189+
| undefined,
1190+
label: string
1191+
): void => {
1192+
Require(
1193+
required && !address?.line1?.trim(),
1194+
`${label} line 1 is required`
1195+
);
1196+
Require(required && !address?.city?.trim(), `${label} city is required`);
1197+
Require(
1198+
required && !address?.postal_code?.trim(),
1199+
`${label} postal code is required`
1200+
);
1201+
Require(
1202+
required && !address?.country?.trim(),
1203+
`${label} country is required`
1204+
);
1205+
};
1206+
1207+
AssertAddress(
1208+
session.billing_address_collection === 'required',
1209+
details?.address,
1210+
'Billing address'
1211+
);
1212+
AssertAddress(
1213+
!!session.shipping_address_collection,
1214+
details?.shipping_address,
1215+
'Shipping address'
1216+
);
1217+
Require(
1218+
!!session.shipping_address_collection &&
1219+
!details?.shipping_address?.name?.trim(),
1220+
'Shipping name is required'
1221+
);
1222+
1223+
Require(
1224+
!!session.tax_id_collection?.enabled &&
1225+
session.tax_id_collection.required === 'if_supported' &&
1226+
!details?.tax_id?.trim(),
1227+
'Tax ID is required'
1228+
);
1229+
1230+
Require(
1231+
session.consent_collection?.terms_of_service === 'required' &&
1232+
!details?.terms_of_service_accepted,
1233+
'Terms of service must be accepted'
1234+
);
1235+
1236+
for (const field of session.custom_fields ?? []) {
1237+
if (field.optional) continue;
1238+
const value = details?.custom_fields?.find(
1239+
(entry) => entry.key === field.key
1240+
)?.value;
1241+
Require(!value?.trim(), `${field.label.custom ?? field.key} is required`);
1242+
}
1243+
}
1244+
10381245
private async MarkPaymentIntentRequiresConfirmation(
10391246
session: CheckoutSession,
10401247
payerWallet: string

0 commit comments

Comments
 (0)